Packaging and Release CI
This page is the contributor-facing reference for how Blackchirp’s
binary releases are produced. The build plumbing — CMake modules, Qt
deployment hooks, and the CPack configuration that ties them together
— lives in cmake/ and is invoked by a single GitHub Actions
workflow at .github/workflows/release.yml. The user-facing install
and verification instructions are in
Installation; this page sits one level below those
and assumes the workflow file is open alongside it.
Binaries are generated on demand. The workflow runs on
workflow_dispatch (with per-platform boolean inputs for
single-job iteration) or on release: published. It does not run
on every push — a typical PR exercises Qt-Test and the docs build but
produces no installers.
Strategy
Cross-platform binary distribution is driven by CPack for the
per-platform formats (.deb, .rpm, .dmg, NSIS, .tgz,
.zip) and by linuxdeploy for the universal Linux AppImage.
One GitHub Actions workflow drives all five platforms.
The Linux matrix is intentionally redundant:
Format |
Target audience |
|---|---|
|
openSUSE (primary build host), Fedora, RHEL — deps auto-derived at build time |
|
Debian, Ubuntu, Mint — |
AppImage |
Universal fallback (Arch, NixOS, anything else); two AppImages per release — one entry point for the main app, one for the viewer |
|
Generic binary tarball for the manual-extraction case |
Snap and Flatpak are intentionally excluded: their sandbox models restrict the serial-port and USB hardware access that Blackchirp requires for acquisition.
macOS ships .dmg (DragNDrop) and .tar.gz in two
architectures — Apple Silicon (arm64) and Intel (x86_64);
CMake does not produce universal binaries by default, so each
runs-on runner emits a single-arch slice and the two together
cover the installed-Mac base. Windows ships an NSIS installer
(.exe) and a .zip.
Components contain only Applications
CPACK_COMPONENTS_ALL is restricted to the Applications
component. The blackchirp-* libraries are all STATIC and
linked into the two executables, so the Libraries (.a
archives) and Development (headers + CMake export files) install
rules are dev-only — useful for cmake --install in source-tree
workflows, useless in a binary package.
CPACK_DEB_COMPONENT_INSTALL / CPACK_RPM_COMPONENT_INSTALL /
CPACK_ARCHIVE_COMPONENT_INSTALL / CPACK_NSIS_COMPONENT_INSTALL
are all ON. Without them, CPACK_COMPONENTS_ALL is silently
ignored for those generators and every install rule (including the
static archives and headers) lands in the package. With Release
builds and stripping enabled, the Applications-only filter yields
packages in the 4–9 MB range on Linux and a Windows .zip that
dropped from ~88 MB to a small fraction of that.
Qt and Qwt sourcing per job
The build jobs source Qt and Qwt differently because each platform imposes different constraints on what can be linked, packaged, and deployed.
Job |
Qt |
Qwt |
|---|---|---|
|
apt |
from-source + |
|
zypper |
from-source + |
|
|
from-source |
|
|
from-source |
|
|
from-source |
The deb job uses Ubuntu’s system Qt because dpkg-shlibdeps reads
Debian’s *.shlibs database to derive dependencies — Qt installed
by install-qt-action has no Debian shlibs metadata and the
cpack -G DEB step fails. Ubuntu LTS does not ship a Qt6 build of
Qwt at all (only the Qt5-era qwt 6.1.4), so the deb job builds Qwt
from source and bundles libqwt.so* inside the package via
BC_BUNDLE_QWT=ON. The executables get an
$ORIGIN/../<libdir>/blackchirp RPATH, and dpkg-shlibdeps
follows it to the bundled lib while resolving Qt sonames through
/usr/lib.
The rpm job follows the same bundle-Qwt pattern, but for a different
reason. openSUSE patches libqwt-qt6.so’s SONAME to include the
minor version (libqwt-qt6.so.6.3 rather than upstream’s
.so.6); RPM AUTOREQ records the linked SONAME verbatim, and
libqwt-qt6.so.6.3 is unsatisfiable on Fedora, RHEL, and any other
RPM distro that ABI-tracks Qwt at the major level. Bundling sidesteps
the soname mismatch entirely — the resulting RPM has no Qwt
dependency at all and installs cleanly on every RPM distro that has
Qt6.
The other three jobs build Qwt from source because no reliable Qt6 Qwt exists on Homebrew, vcpkg, or any LTS apt channel.
The Qwt cache is split into actions/cache/restore@v5 +
actions/cache/save@v5 rather than the unified actions/cache@v5
because the unified action’s implicit post-step skips on job
failure — wasting the rebuild on every retry. The split form gates
the save on cache-hit != 'true' and places it immediately after
the build step, so a downstream failure does not invalidate the
saved Qwt.
AppImage specifics
Two AppImages per release
The Linux AppImage job emits both Blackchirp-x86_64.AppImage and
Blackchirp-Viewer-x86_64.AppImage. Each is fully self-contained
— bundled Qt/Qwt/GSL is the size driver and is duplicated across the
two — but the duplication is deliberate: AppImage users are exactly
the audience without a system package manager that pulls in both
binaries, so click-and-run discoverability beats download
efficiency. The main AppImage bundles both binaries internally, so
users who care about the size can run the viewer from inside it via
--appimage-mount or --appimage-extract (the recipe is in
Launching the viewer from the main AppImage).
The build runs linuxdeploy twice against two AppDir copies. The
plugin mutates the AppDir in place (RPATH patches, AppRun injection,
libdir cleanup), so a single tree cannot be reused for two outputs —
the Stage AppDir step does cp -a AppDir AppDir-viewer before
linuxdeploy runs. OUTPUT= is set explicitly for the viewer
build; without it, appimagetool would mangle
Name=Blackchirp Viewer (with a space) to
Blackchirp_Viewer-x86_64.AppImage (with an underscore), breaking
the docs’ Blackchirp-Viewer-* glob.
glibc floor
The AppImage build pins runs-on: ubuntu-22.04 rather than
ubuntu-latest. AppImages bundle Qt, Qwt, libgsl, and the rest of
the executable’s library closure but not glibc / libm — those
always come from the host loader. Symbol versions picked up by
bundled libraries at link time therefore become a hard host-glibc
minimum at run time: a libgsl.so.27 built against glibc 2.39
references GLIBC_2.38-versioned symbols in libm and fails to
load on any host older than that, defeating the AppImage’s
“universal fallback” purpose. Building on ubuntu-22.04 (glibc
2.35) caps the floor at the LTS distros the AppImage exists to serve
— Ubuntu 22.04+, RHEL 9+, openSUSE Leap 15.5+, Debian 12+. The Qwt
cache key bakes in the runner codename (jammy) so a future
runner upgrade auto-invalidates the cache rather than poisoning a
new build with stale glibc-2.39-linked artifacts.
The companion linux-appimage-smoke job stays on
ubuntu-latest (newer than the build runner) to verify forward
compatibility. It cannot, by construction, catch
backward-incompatibility against an older host than the build
runner — that gap is closed by the manual clean-VM pass on a
22.04-class system.
Qt redistribution into the package
How Qt ends up alongside the binary at install time differs per platform:
- Linux DEB and RPM
The distro package manager resolves Qt at install time via auto-derived
dpkg-shlibdeps(DEB) or RPM AUTOREQ (RPM). Both jobs additionally shiplibqwt.so*bundled (see above).- Windows and macOS
windeployqt/macdeployqtrun asinstall(CODE)hooks registered bycmake/QtDeployment.cmake. macOS passes-libpath=<qwt-install/lib>so macdeployqt can locate the from-sourcelibqwtby basename and bundle it intoContents/Frameworks/; qmake’s macOS build leaves the dylib’sinstall_namepointing at/usr/lib/..., which does not exist on the runner.- AppImage
linuxdeploy-plugin-qtwalks the executable’s library closure and bundles everything into the AppImage.LD_LIBRARY_PATHmust include$QT_ROOT_DIR/liband the from-sourceqwt-install/libduring the linuxdeploy step, otherwiselddreports Qt sonames as unresolved and the plugin refuses to bundle them.
Windows additionally needs a hand-rolled second windeployqt
invocation against qwt.dll. The qmake-built qwt.dll imports
Qt6OpenGL.dll / Qt6OpenGLWidgets.dll, but the first
windeployqt pass walks the executable’s modules only and misses
those transitive dependencies. cmake/QtDeployment.cmake
registers a separate install(CODE) block that runs
windeployqt against qwt.dll with --dir anchored on the
install bin so the missing DLLs land alongside the executable.
Signing and provenance
GPG signing covers the Linux artifacts; build-provenance attestations cover all five platforms.
Artifact |
Signature form |
How users verify |
|---|---|---|
|
embedded ( |
|
|
detached |
|
AppImage |
detached |
|
|
ad-hoc codesign ( |
launches on a clean Mac after |
|
unsigned |
(build-provenance only — see below) |
any of the above |
GitHub build-provenance |
|
The release key is a 4096-bit RSA GPG key, ID 898734DF7EDBDE45,
dedicated to release signing. Public key:
packaging/blackchirp-release.asc, also attached to every GitHub
release by the deb job and published on keys.openpgp.org. The
private key and passphrase live in repository Actions secrets
(GPG_PRIVATE_KEY, GPG_PASSPHRASE, GPG_KEY_ID). Offline
backup of the secret key is the maintainer’s responsibility — if
both the local keyring and the offline backup are lost, no future
release can be signed with a key the existing user base trusts.
DEB and AppImage use detached .asc rather than embedded signing
because apt does not verify in-.deb signatures (the apt trust
model signs the repository’s Release file, not individual
.deb files) and AppImage’s appended-signature scheme has
near-zero downstream consumer support; a side-car .asc users
verify with stock gpg --verify is the most broadly-supported
form. RPM uses embedded signing because that is exactly what
rpm --checksig and zypper / dnf install consult at
install time.
Neither macOS nor Windows uses GPG signing. The Windows NSIS installer is unsigned: Authenticode would need a purchased certificate, and self-signing makes SmartScreen warn harder rather than less.
The macOS bundle is ad-hoc codesigned. macdeployqt rewrites
LC_LOAD_DYLIB / install_name entries and copies framework
dylibs into the bundle, which invalidates the linker-applied ad-hoc
signature on the arm64 main executable and the upstream signatures
on the copied frameworks; the kernel SIGKILLs an
invalidly-signed arm64 binary outright. cmake/QtDeployment.cmake
therefore runs codesign --force --deep --sign - over the whole
bundle after macdeployqt, and the macos-smoke job verifies
it with codesign --verify --deep --strict. This is an ad-hoc
signature, not Apple Developer ID notarization: Gatekeeper still
flags the downloaded app as “damaged” until notarization is in
place, and users clear that with xattr -d com.apple.quarantine
(documented in Installation). Build-provenance
attestations apply to every artifact regardless of OS-level signing.
The RPM-signing step writes rpm-sign macros into
~/.rpmmacros so rpmsign --addsign can drive GPG
non-interactively. The passphrase is read from a temp file created
with umask 077 and shred -u-d by an EXIT trap. The
%__gpg_sign_cmd macro replaces GPG’s default pinentry with
--pinentry-mode loopback --passphrase-file <file> so the
container has no TTY for prompts. After signing, rpm --checksig
runs against the signed file, but only after rpm --import has
populated rpm’s own keyring (separate from gpg’s) with the public
key — without the import, --checksig reports
SIGNATURES NOT OK even on a valid signature.
actions/attest-build-provenance@v2 produces a Sigstore-signed
SLSA provenance record proving each artifact was built by a specific
workflow run on a specific commit. Keyless via OIDC against
Sigstore’s Fulcio CA; nothing to manage or rotate. The workflow
requires top-level permissions: id-token: write and
attestations: write; without these the action errors out at the
OIDC-token mint step.
CMake modules and packaging files
cmake/Packaging.cmakeCPack configuration: package metadata, per-OS generator selection (
DEB;RPM;TGZon Linux,DragNDrop;TGZon macOS,NSIS;ZIPon Windows), component definitions, and platform-specific knobs. Owns theBC_BUNDLE_QWToption, the Windows third-party-DLL bundling logic, the per-format file-name overrides, and the$ORIGIN-relative RPATH that points the executables at the bundled libqwt on Linux. Strips binaries in Release. Drives thepackage-all,package-deb,package-rpm,package-nsis,package-dmgcustom targets.cmake/QtDeployment.cmakeProvides
blackchirp_deploy_qt(<target>). Locateswindeployqt/macdeployqtfromQt6::qmake’sIMPORTED_LOCATIONand registersinstall(CODE)hooks that run the right tool against the installed binary at packaging time. macOS derives-libpath=fromQWT_LIBRARYso the from-source libqwt bundles correctly, then — because theVERSIONtarget property leavesContents/MacOS/<target>a symlink andcodesignrefuses a symlinked main executable — collapses that symlink onto the versioned binary and runs the ad-hoccodesign --force --deep --sign -pass over the bundle. No-op on Linux.cmake/BlackchirpApplication.cmake/cmake/BlackchirpViewerApplication.cmakePer-app target wiring. Each sets
MACOSX_BUNDLE_*properties (info plist, copyright, icon, version), installs toBUNDLE DESTINATION .(DragNDrop DMG convention) andRUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}, then callsblackchirp_deploy_qt(<target>).cmake/FindQWT.cmakeWhen
qwt.his found inside aqwt/qwt-qt6/qwt6subdirectory, exposes the parent as a second include path so<qwt6/qwt_plot.h>resolves (the openSUSE convention used in the source). On Windows, setsQWT_DLLon the imported target’sINTERFACE_COMPILE_DEFINITIONSwhenqwt.dllis present next to the import lib so MSVC consumers get__declspec(dllimport)on exported static data members.packaging/Per-platform packaging assets. The full per-file map:
Path
Role
packaging/macos/Info.plistBundle metadata for
blackchirp.apppackaging/macos/ViewerInfo.plistBundle metadata for
blackchirp-viewer.apppackaging/linux/postinstupdate-desktop-databaseafter DEB installpackaging/linux/prermupdate-desktop-databasecleanup before DEB removepackaging/blackchirp.desktop.inXDG desktop file for the main app (substituted via
configure_file)packaging/blackchirp-viewer.desktop.inXDG desktop file for the viewer
packaging/blackchirp-release.ascPublic half of the GPG release signing key, attached to every GitHub release by the deb job
icons/blackchirp.icnsMulti-resolution macOS bundle icon
src/resources/icons/bc_logo_large.pngSource logo; used for the Linux pixmap install
Workflow structure
.github/workflows/release.yml defines five build jobs (one per
platform; the macOS job is a matrix over arm64 and x86_64,
so six job executions per full run) and the matching *-smoke
jobs that install each artifact in a clean-ish environment and
confirm <binary> --version exits zero. The smoke layer catches
the common packaging-step regressions: missing bundled libs, broken
RPATHs, soname mismatches, the wrong Qt module set in the bundle.
It does not exercise GUI initialization — --version
early-returns before QApplication is constructed so headless
containers can invoke it without QT_QPA_PLATFORM=offscreen.
Per-job skeleton:
Install system dependencies (apt / zypper / brew / vcpkg / chocolatey).
Install Qt (per the sourcing matrix above).
Restore or build Qwt 6.3.0 from source (cached per OS).
cmake → cmake --build → ctest.cpack(orlinuxdeployfor AppImage). On macOS theQtDeployment.cmakeinstall hook ad-hoc codesigns the bundle here, aftermacdeployqt; the matchingmacos-smokejob later runscodesign --verify --deep --strict.Sign Linux artifacts (detached for DEB/AppImage, embedded for RPM).
actions/attest-build-provenance@v2.actions/upload-artifactand, on release events,gh release upload --clobber.
The rpm job runs inside opensuse/leap:16.0. Container jobs have
a quirk: ${{ github.workspace }} templates to the runner host’s
path (e.g., /home/runner/work/...), which does not exist on the
container’s bind mount. Only the $GITHUB_WORKSPACE env var
points at the mounted workspace (e.g., /__w/blackchirp/blackchirp).
Writes to the host-path string from inside the container land on the
container’s local filesystem and are invisible to the host — including
the actions/cache action that runs on the host. Container-side
script paths in the rpm job therefore reference $GITHUB_WORKSPACE;
only actions running on the host (actions/cache/*) use the
templated host path.
Crash-log → symbol-artifact triage is documented separately in
Crash Handling and Triage. Each build job uploads
blackchirp-symbols-<platform> alongside the package (90-day
retention) and embeds the workflow run_id plus git_sha in a
symbols-manifest.json inside the artifact, so a triager
downloading a crash log can locate the matching debug-info bundle
without guessing.
Non-intuitive constructions
A handful of choices in the packaging plumbing exist for non-obvious reasons and should not be “tidied up” without rediscovering why they look the way they do.
include(GNUInstallDirs)is called early in the top-levelCMakeLists.txt, before any subdirectoryinstall()rule. Otherwise subdirectory rules fall back to absolute paths (e.g.,/blackchirpfor the Python templates), which breaks every CPack generator.macOS bundle metadata lives on the executable targets, set via
MACOSX_BUNDLE_*target properties. The DragNDrop CPack generator picks them up automatically; do not useCPACK_BUNDLE_*, which applies to the separateBundlegenerator and silently ignoresMACOSX_BUNDLE_*.Both apps install with
BUNDLE DESTINATION .so the.applands at the install-prefix root. Matches the DragNDrop DMG layout (drag the.appstraight onto Applications) and is the pathblackchirp_deploy_qtlooks for at install time.CPACK_SET_DESTDIRis left unset on Apple. DESTDIR-style staging is right for DEB/RPM/IFW, but for DragNDrop the.appis the unit of distribution and the package root is the install root; withDESTDIR=ON, CPack stages the.appunder${DESTDIR}/usr/local/blackchirp.appand both the deploy hook and the DragNDrop file walk miss it.MACOSX_DEPLOYMENT_TARGETis pinned to13.3on both macOS jobs, not left at the runner default. Apple’s libc++ marks the floating-pointstd::to_charsoverloadsintroduced=13.3, so a lower target fails to compile the shortest-roundtrip formatting insrc/gui/util/numericformat.cppandsrc/gui/widget/scientificspinbox.cpp. 13.3 is therefore the binary’s minimum macOS; the user-facing per-artifact minimum-OS table in Installation is the single source of truth for the published floors.Distro package names are not hard-coded. DEB dependencies come from
dpkg-shlibdeps; RPM dependencies come fromAUTOREQ. Hard-codedDepends:lines drift across Ubuntu/Debian releases and across openSUSE/Fedora’s qt6/gsl/qwt package names; auto-derivation tracks each distro’s actual shipped sonames.Eigen3 has no version pin. The development-box system Eigen is 5.0.1 and its CMake config rejects pre-5 minimum-version requests. If a CI runner ships Eigen 3.x and a minimum needs to be enforced, change
find_package(Eigen3 REQUIRED)tofind_package(Eigen3 3.3...<6 REQUIRED).blackchirp.icnswas generated locally withicnsutilfromsrc/resources/icons/bc_logo_large.pngand is checked in. Regenerate only if the source logo changes.AppImage icon lookup uses the hicolor 256×256 path, not
share/pixmaps/blackchirp.png. The pixmap is 1024×1024 (sized for.icns/.icomasters), and linuxdeploy’s icon validator caps at 512×512.