/opt/<appname>.
- Shell 39.9%
- CMake 26.3%
- Python 19.8%
- Dockerfile 13.9%
- C 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
image / image (push) Has been cancelled
wxQt shipped underlinked. build/cmake/init.cmake turns wxUSE_DETECT_SM on
whenever `pkg-config sm` resolves - and it resolves here regardless, since
libSM-devel is part of the system stack Qt needs - so apptraits.cpp's
SmcOpenConnection was compiled into libwx_qtu_core while nothing linked -lSM:
SM_LIBRARIES is set in init.cmake and read by no target anywhere. wxGTK never
notices because find_package(X11) drags the client libraries in sideways; wxQt
takes that branch never, and every program that linked the result died on
`undefined reference to SmcOpenConnection'. The overlay port passes
wxUSE_DETECT_SM=OFF (port-version 2), which costs only the session-manager
fallback in GetDesktopEnvironment() - reached only when XDG_CURRENT_DESKTOP is
empty. Linking -lSM instead would put libSM.so.6 in the NEEDED list of every wx
program out of this image.
check-env.sh grows the check that would have caught it - a `nm -D` pass for
undefined Smc*/Ice* in any libwx_*.so - and two fixes where it was reporting
things that were not true:
- find_package leaks the package's own variables into the caller's scope, and
wxWidgetsConfig.cmake ends on `string(SUBSTRING ${libname} 2 -1 name)`. With
a loop variable called `name` the pass came back reporting a package nobody
asked for as missing and saying nothing about wxWidgets. One package per
function call now.
- `objdump -p | grep -q` under `set -o pipefail` reads a non-zero objdump exit
- including the SIGPIPE grep -q can cause - as the NEEDED entry being absent,
with stderr discarded. It reported a wx/Qt linkage problem that did not exist,
on one build of two identical cached layers. Output and exit status are taken
separately now, and a tool that could not run says so. Same for the Qt/OpenSSL
check, where four different failures arrived as the rarest one's message.
setvars.sh walks tools/<port>[/bin] for directories holding an executable
rather than listing them: vcpkg's layout is not uniform, and that skips the
debug copies and ICU's config/ without naming either.
.forgejo/workflows/build-image.yml builds the image on the docker (DinD) runner
label, smoke-tests it and pushes :latest plus a commit tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XwW1XJbzrUwdiE95BfesF
|
||
| .forgejo/workflows | ||
| cmake | ||
| examples | ||
| overlay-ports | ||
| overlay-triplets | ||
| scripts | ||
| vcpkg-qt | ||
| .dockerignore | ||
| .gitignore | ||
| build-image.sh | ||
| Dockerfile | ||
| README.md | ||
| test-portability.sh | ||
| vcpkg.json | ||
linuxbuildenv — portable Linux build environment
The Linux counterpart of winbuildenv: a container that
builds applications which run on any reasonably current Linux, from
AlmaLinux 9 to Arch, with the whole library set, Qt and the toolchain baked in
and a deployment tool that lays the result out under /opt/<appname>.
| base | AlmaLinux 9 |
| ABI baseline | glibc 2.34, GLIBCXX_3.4.29, CXXABI_1.3.13 |
| compiler | gcc-toolset-14 (gcc 14.2, C++23) |
| debugger | gdb 16.3, natively |
| libraries | /opt/wiselibs, shared and static, built from source |
| Qt | /opt/qt, 6.11.2, shared, built from source |
| .NET | /opt/dotnet, SDK 10.0.400 (runtime 10.0.11, LTS) |
| Node.js | /opt/node, 24.20.0 LTS |
Why AlmaLinux 9
A dynamically linked ELF runs on any system whose glibc is at least the one it was built against, and on no system older than that. So the base distribution is not a preference, it is the floor: building on AlmaLinux 9 is what makes one set of binaries run on AL9, RHEL and Rocky 9, Debian 12 and 13, Ubuntu 22.04 onwards, Fedora and Arch alike. Building on Arch and hoping produces binaries that run on Arch.
AL9's own compiler is gcc 11.5, which is not a C++23 compiler, so the build uses
gcc-toolset-14. That works without raising the floor because the toolset
ships no libstdc++.so of its own: symbols newer than the base system's
libstdc++ are linked in statically from libstdc++_nonshared.a. A C++23 binary
built here — std::format, std::jthread, ranges — still asks only for
GLIBCXX_3.4.29, and still runs on a stock AL9 machine.
That is checked, not assumed. scripts/check-baseline.sh reads a binary's
version references and compares them against what this container's own glibc
and libstdc++ provide:
check-baseline.sh dist/ # a whole tree
check-baseline.sh --baseline # just the numbers
The image's last build step compiles a C++23 program and runs it through this,
so an image that could produce non-portable binaries fails to build. It is also
run automatically at the end of every wisedeploy.
Proving it
check-baseline.sh is an argument about symbol versions. test-portability.sh
is the experiment: it takes a deployed tree, drops it into a stock container of
each target distribution — no build tools, no Qt, no OpenSSL 3.6, nothing this
image put anywhere — and runs it.
./test-portability.sh /tmp/deploy-out/wisedemo-linux-x86_64.tar.gz wisedemo/bin/smoke
examples/smoke, built once here and deployed with wisedeploy, was verified
to run unmodified on all of:
| almalinux:9 | rockylinux:9 | debian:12 | debian:trixie |
| ubuntu:22.04 | ubuntu:24.04 | fedora:41 | archlinux:latest |
reporting openssl 3.6.3, sqlite3 3.53.4, libsodium 1.0.22, lua 5.5.0, zxing-cpp 3.1.1 on every one of them — the pinned versions it was built against, not whatever the host distribution happens to ship.
One consequence worth knowing: the image deliberately does not set
LD_LIBRARY_PATH to the toolset's lib64 the way /opt/rh/gcc-toolset-14/enable
would. There is nothing there to find, and leaving it unset keeps the container
honest — anything that runs in here runs on the baseline, because in here is
the baseline.
Libraries
argon2, gtest, liblzma, libmariadb[iconv], libpcap, libpq[lz4,openssl,zlib],
libslirp, libsodium, lua, lz4, openal-soft, openssl, pugixml, sdl2,
sqlite3[omit-load-extension], wxwidgets, zlib, zxing-cpp — plus icu, which
winbuildenv gets from Windows and this image has to build.
The same list as winbuildenv, from the same pinned vcpkg commit and the same
version overrides, so "openssl 3.6.3 on both platforms" is a fact rather than a
coincidence. Two triplets:
| triplet | ||
|---|---|---|
x64-linux-dynamic |
shared | $LIBS_ROOT, the default |
x64-linux-static |
static, -fPIC |
$LIBS_STATIC_ROOT |
-fPIC on the static tree is the one Linux-specific decision in the set: a
static archive built without it cannot be linked into a shared library, and
every product here that ships a .so does exactly that. Without it the failure
is a link error deep inside the consumer — "relocation R_X86_64_32 against
.rodata can not be used when making a shared object" — which reads like a bug
in the consumer and is not.
Two manifests, because of build order
vcpkg.json is not the whole set. wxWidgets is built with the wxQt backend
against this image's own Qt (below), so it cannot be
built until /opt/qt exists, and Qt in turn is built against openssl, icu,
sqlite, libpq and libmariadb from the first pass. So the set is built in two
passes and merged into the same trees:
vcpkg.json |
everything Qt is built against, and everything that does not care — the libs stage |
vcpkg-qt/vcpkg.json |
wxWidgets alone — the qtlibs stage, after Qt |
Both use the same overlay-ports/ and overlay-triplets/, and the second pass
runs build-libs.sh with MERGE=1 so it adds to the triplet trees rather than
replacing them. Everything downstream — $LIBS_ROOT, the toolchain file,
find_package — sees one tree and never has to know.
Overlay ports
overlay-ports/ carries the two ports the Windows side carries, copied verbatim
so the two environments stay in step, plus one that exists only here:
- zxing-cpp 3.1.1 — not in vcpkg mainline, which renamed the port and left it at 2.3.0.
- libmariadb 3.4.8 — the patch that matters on Windows, a missing
STDCALLonmysql_load_plugin, is a no-op here sinceSTDCALLexpands to nothing off Windows; the port is carried anyway so the version and the patch set are decided in one place. - wxwidgets 3.3.3 — mainline's port with the toolkit changed from GTK to Qt and the GNOME half of its dependency list removed. Below.
The exception: the system stack
The graphics, input and audio libraries — libX11, libGL, libwayland, libxcb, libxkbcommon, dbus, systemd, fontconfig — are the distribution's, are linked against as system libraries, and are never bundled. Bundling one machine's libGL does not make a program more portable; it is how a program stops working on the next machine, whose driver stack differs. Everything else is self-compiled.
Keeping that true when a GUI toolkit enters the library set takes two decisions,
because vcpkg's own answer would have put dbus, libsystemd and fontconfig in
/opt/wiselibs — where the path rule bundles them: wxWidgets is built as
wxQt rather than wxGTK, and SDL2 is built
without the D-Bus feature.
Two entries in the set do come from the vcpkg side of that line and are meant
to. glib arrives because libslirp links it, and is ordinary self-contained
code with no daemon or driver behind it — it bundles as safely as PCRE2 does.
alsa arrives because openal-soft's port wants its headers, but nothing links
it: OpenAL Soft and SDL2 both dlopen libasound.so.2 by soname, so what a
program actually uses is the target machine's, and wisedeploy's NEEDED
closure never sees it.
wxWidgets is wxQt here
wxWidgets is the one library in the set that could not be taken from vcpkg as it comes, and the reason is this same rule.
wxWidgets on Linux is normally a GTK front end, and vcpkg's port depends on
vcpkg's own gtk3 rather than the machine's. Building it that way pulls the
whole GNOME stack into /opt/wiselibs — glib, gdk-pixbuf, pango, cairo, atk,
libepoxy, libX11/libXi/libXrandr, and through at-spi2-core a second copy of
dbus and libsystemd; cairo brings fontconfig, freetype and
harfbuzz with it. Four of those are named in the table
below as libraries that must come from the host — and
wisedeploy bundles by path, with ldd resolving sonames against
/etc/ld.so.conf.d. Once they are in /opt/wiselibs, every GUI deployment out
of this image starts carrying them, Qt programs included, and nothing warns.
So wx is built against Qt instead. wxWidgets has had a Qt backend for years,
build/cmake/toolkit.cmake takes wxBUILD_TOOLKIT=qt and asks for Qt 6's
Core Widgets Gui OpenGL OpenGLWidgets Test PrintSupport — all of which this
image already builds — and overlay-ports/wxwidgets is mainline's port with
that switch flipped and gtk3, cairo and libsm removed from its
dependencies. None of the GNOME stack is ever built. A wx program here links
libQt6Widgets.so.6 out of /opt/qt, which wisedeploy already knows how to
deploy, and the rule above keeps meaning what it says.
The dependencies drop out cleanly rather than being forced: wxUSE_CAIRO
defaults off when the toolkit is not GTK, find_package(X11 REQUIRED) is
guarded by the X11/GTK ports, and the fonts and media features' lookups of
fontconfig/pangoft2 and gstreamer are guarded by AND WXGTK in wxWidgets' own
build/cmake/init.cmake.
libsm is the one that does not, and the port passes wxUSE_DETECT_SM=OFF to
finish the job. Removing the dependency is not enough, because init.cmake
keeps that option on whenever pkg-config sm resolves — and it resolves here
regardless, since libSM-devel is part of the system stack
Qt needs. The option then compiles src/unix/apptraits.cpp's SmcOpenConnection
call into libwx_qtu_core while nothing links -lSM: SM_LIBRARIES is set in
init.cmake and read by no target anywhere in wxWidgets' CMake build. wxGTK
never notices because find_package(X11) in build/cmake/toolkit.cmake —
guarded by WXX11 OR WXGTK2 OR (WXGTK AND wxHAVE_GDK_X11) — drags the X11
client libraries in sideways. wxQt takes that branch never, so the library
shipped underlinked and every program that linked it died on undefined reference to SmcOpenConnection'. Turning it off costs one fallback:
wxGUIAppTraits::GetDesktopEnvironment() asks the session manager for its
vendor string only when XDG_CURRENT_DESKTOP is empty, which no current
desktop leaves empty. Linking -lSM instead would keep it, at the price of
libSM.so.6 in the NEEDED list of every wx program out of this image — a
system-path library wisedeploy does not bundle, so every target machine would
then have to have it.
What it costs. Upstream calls wxQt experimental — docs/readme.txt lists
GTK support unqualified and "Most Unix variants with Qt 5 or newer
(experimental)" beside it. It is maintained (3.3.3 added private font support to
wxQt) but it is the least complete of the wx ports, and a program that behaves
under wxGTK can still find a gap here. That is a deliberate trade, and it is
one-sided: Windows is unaffected, winbuildenv builds wxMSW, which is a
first-class port and involves no GTK at all.
check-env.sh asserts it rather than trusting it: the image fails to build if
libwx_qtu_* is missing, if wx is not linked against Qt 6, if any libwx_*.so
has an undefined Smc*/Ice* symbol, or if libgtk-3, libdbus-1,
libsystemd or libfontconfig has appeared in $LIBS_ROOT/lib.
SDL2 is built without the D-Bus feature
Same argument, one line of manifest. vcpkg's sdl2 enables dbus by default
on Linux, and that feature depends on the dbus port, which pulls libsystemd
and behind it libmount, libcap and libxcrypt — the last things left that would
put libdbus-1.so.3 and libsystemd.so.0 on this image's loader path after
wxWidgets stopped doing it. So the manifest asks for x11 and wayland only:
{ "name": "sdl2", "default-features": false, "features": [ "wayland", "x11" ] }
Neither of those features has a vcpkg dependency — SDL finds the distribution's
X11 and Wayland headers at build time and dlopens the libraries at run time,
which is why libSDL2.so's only NEEDED entries are libc and libm. The
same is true of OpenAL Soft and its ALSA and PulseAudio backends.
What is given up is SDL's D-Bus screensaver inhibition and its ibus IME bridge.
If a program needs them, put the feature back and accept that libdbus-1 and
libsystemd become part of what wisedeploy can bundle. Windows is unaffected:
every one of those features is platform: linux in the port, so
winbuildenv/vcpkg.json can and does name sdl2 plainly.
Qt
Qt 6.11.2 from the official whole-source tarball, built natively into /opt/qt,
shared libraries only so LGPL relinking stays possible.
One pass, not two. The Windows image builds Qt twice — a Linux host pass for the
code generators and a cross pass for the target — because moc cannot run on
the machine it is being built for. Here host and target are the same machine, so
there is no -qt-host-path, no QT_HOST_PATH, no separate tools build, and
none of the "which qmake did PATH give me" hazard that dominates the Windows
script. qmake -query QMAKE_XSPEC says linux-g++ and there is no second
answer it could give.
Skipped: qtwebengine (Chromium — hours of build time and a dependency set of
its own; add it deliberately if a project needs it), qtdoc, qtactiveqt
(Windows-only). Everything else in the tarball is built, plus qtopcua,
qtcoap and qtmqtt, which ship separately.
SQL drivers: QSQLITE, QMYSQL, QPSQL and QODBC, against the pinned
library set rather than the distribution's.
ICU comes from the library set, not from the distribution, and that
distinction is the whole trick. Linked against the system ICU this is the
worst dependency in the build: ICU's soname carries its major version —
libicuuc.so.67 on AlmaLinux 9, .so.72 on Debian 12, .so.74 on Ubuntu
24.04, .so.76 on Arch — so libQt6Core would run on RHEL 9 and nowhere else.
Forward compatibility does not help; the file is simply not there. This was
caught by test-portability.sh, not by reading the configure summary.
Built as part of the library set instead, it is ICU 78.3 — newer than any
distribution currently ships — and wisedeploy bundles it through the same path
rule as OpenSSL and sqlite, with no special case anywhere. -DICU_ROOT is
stated explicitly so configure cannot quietly find the system copy.
PCRE2, libpng, libjpeg and double-conversion are bundled into Qt itself for a
related reason: a stable soname is not the same as the library being installed.
Fedora and RHEL split PCRE2 so that the 16-bit build lives in pcre2-utf16, a
package a machine can easily lack — and libQt6Core needs libpcre2-16.so.0,
so on such a machine nothing runs at all.
The font stack — freetype, harfbuzz, fontconfig — is deliberately not
bundled, and that is load-bearing. Qt's fontconfig feature requires system
freetype: pass -qt-freetype and configure turns fontconfig off silently,
leaving a Qt that builds, links, runs and cannot enumerate a single font the
machine has. check-env.sh now asserts QT_FEATURE_fontconfig for that reason.
TLS: configured -openssl-linked, where the Windows build loads OpenSSL at
runtime. Linking it makes the dependency an ordinary NEEDED entry, so
wisedeploy finds libssl.so.3 by walking the ELF like everything else and no
special case exists to be forgotten. It also removes the chance of Qt picking up
the host's OpenSSL, which on an older distribution is a difficult failure to
diagnose.
Known gaps
Two modules configure but come out less than complete, both because AlmaLinux 9 does not package what they want:
- qtmultimedia builds with no media backend. Qt 6.11 wants FFmpeg, which
RHEL 9 does not ship (it is an RPM Fusion package), and the GStreamer fallback
needs more of the plugin set than CRB carries. Playback APIs will exist and do
nothing. To fix it for a project that needs it, add
gstreamer1-plugins-bad-free-devel— and an FFmpeg — to thetoolchainstage and rebuild. - qtopenapi is skipped: its generator needs a JRE and Maven.
Neither affects anything else in the build. configure reports both, so they
are visible in the image build log rather than discovered later.
.NET 10
The SDK is in /opt/dotnet, on PATH as dotnet, with both shared frameworks
— Microsoft.NETCore.App and Microsoft.AspNetCore.App 10.0.11. It is here so
that one image builds the C++ tree, publishes the managed half and lays both out
with wisedeploy, rather than handing the second half to another container.
dotnet publish src/WiseLicenseManager/WiseLicenseManager.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
--output out/linux-x64
win-x64 and win-x86 publish from here too — a self-contained .NET publish is
a file-copy operation, not a compile, so cross-publishing needs nothing the
Windows image has.
An exact SDK version, not --channel 10.0, and the same one winbuildenv
pins — 10.0.400, checked against the same SHA512, installed to the same
/opt/dotnet. That parity is the point: the manager publishes for linux-x64
here and for win-x64 there, and those should not be two different programs.
The devcontainer in the licensing tree pins only the major version and takes
whatever point release is current, which is the right call for a development
box and the wrong one for an image whose entire argument is that two machines
building the same source get the same binaries.
The published output holds to the image's own baseline — check-baseline.sh
reads wiselicmgr as glibc 2.27, comfortably under the 2.34 floor, because
Microsoft builds the native runtime against something older than AlmaLinux 9.
A self-contained .NET app is not self-contained about ICU
Worth knowing before it bites, because it is the same shape as the OpenSSL config problem below: the failure is quiet, late, and nothing in the ELF header predicts it.
--self-contained true bundles the runtime, the framework and every assembly —
80 MB for a hello-world — and still does not bundle ICU. .NET reaches
globalization data by dlopening libicuuc.so.<version> at the first
culture-aware call, so the dependency appears in no import table, ldd says
nothing, and wisedeploy's ELF walk cannot see it either. On a machine without
ICU the program starts, runs, prints, and then dies partway through:
runtime .NET 10.0.11
os linux-x64
invariant False
Process terminated. Couldn't find a valid ICU package installed on the system.
That is the stock almalinux:9, debian:12, ubuntu:24.04 and fedora:41
images — none of them carry ICU in the base layer, and archlinux:latest
happens to. Installing the distribution's own (libicu, libicu72,
libicu74, …) fixes it on all of them; that package belongs in the Depends
or Requires of anything built here that ships managed code, beside the
graphics stack listed below.
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 removes the dependency and is not a
drop-in: since .NET 8 a new CultureInfo("de-DE") under invariant mode throws
CultureNotFoundException unless the app also sets PredefinedCulturesOnly
to false. It is a deliberate application decision, not a deployment switch. The
third option, if the version matters as much as it does for the rest of the set,
is app-local ICU through the Microsoft.ICU.ICU4C.Runtime package — which is
the .NET spelling of exactly what this image does with ICU for Qt.
Inside the container ICU resolves to the library set's 78.3 rather than
AlmaLinux's 67, because /opt/wiselibs/x64-linux-dynamic/lib is on the loader
path and .NET probes high versions first. Either satisfies it; check-env.sh
asserts that one of them is findable.
Deployment: wisedeploy
This is what the image is for. A program built here links OpenSSL 3.6.3,
libsodium, ZXing and Qt 6.11 — none of which the target machine has — and needs
them to travel with it, found without LD_LIBRARY_PATH, a wrapper script, or
anything the user has to remember.
. setvars.sh
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build
wisedeploy --appname myapp --qmldir src --tarball build/myapp
produces
/opt/myapp/
bin/myapp rpath $ORIGIN/../lib
bin/qt.conf where the plugins, QML modules and translations are
lib/*.so* everything self-compiled, rpath $ORIGIN
plugins/<type>/*.so the Qt plugins the program can actually use
qml/ QML modules
translations/*.qm
which is exactly the payload an AppImage, a Flatpak, a .deb or an .rpm
wants, and works untouched as a plain tarball. --dir puts it somewhere other
than /opt/<appname>; --tarball also writes <name>-linux-x86_64.tar.gz.
What gets bundled is a path rule: everything resolving under
/opt/wiselibs, /opt/qt or /opt/plxlib is ours and is copied; everything
else belongs to the target machine. --bundle-root adds a prefix.
The path is a proxy for the real question, which is worth stating, because "system library" covers two very different things. A library genuinely has to come from the host when it is coupled to something only that machine knows:
glibc — libc, libm, libresolv, ld-linux |
the dynamic loader is part of it; a bundled libc under the host's loader breaks NSS, dlopen and locales |
libGL, libGLX, libEGL, libOpenGL, libgbm, libdrm, libvulkan |
libglvnd picks the vendor library at runtime; one machine's copy will not match the next machine's driver |
libudev, libsystemd, libdbus-1 |
they talk to the daemons actually running on that box |
fontconfig, and freetype/harfbuzz beside it |
fontconfig reads /etc/fonts and must agree with the machine's font configuration |
libstdc++, libgcc_s |
bundleable in principle; unnecessary here because of the ABI baseline |
Everything else — ICU, PCRE2, OpenSSL, zlib, sqlite, brotli, krb5, unixODBC — is
ordinary self-contained code with none of that coupling, and is bundleable. So
the rule is not "system libraries stay behind"; it is "libraries that depend on
this machine stay behind". Anything else you want to control the version of
belongs in /opt/wiselibs, where the path rule collects it automatically —
which is exactly why ICU is built there rather than taken from the distribution.
Plugins are chosen by asking, not by a table. Each Qt plugin is inspected
for the Qt modules it needs and deployed when the modules actually deployed
satisfy it; then its own dependencies are resolved too. That is the same
question windeployqt's hardcoded module→plugin table approximates, asked
directly, and it is why the PostgreSQL driver arrives with libpq.so.5 behind
it without anybody naming either.
QML modules are deployed before the Qt plugins, and their plugins go through
the same dependency closure as everything else. Both halves of that matter. A
QML module is mostly text — qmldir, .qml, .qmltypes — but most modules also
carry a plugin .so, and that plugin links Qt modules the application never
does. QtQuick.Controls is the case that bites: libqtquickcontrols2plugin.so
needs libQt6QuickControls2Impl.so.6, a library that exists only to back the
Controls implementation, so nothing in the application's own ELF closure ever
mentions it. Copying the module tree verbatim produces a deployment that looks
complete and dies on the first import QtQuick.Controls with
Cannot load library .../libqtquickcontrols2plugin.so:
libQt6QuickControls2Impl.so.6: cannot open shared object file
Running the closure over the QML plugins fixes it, and doing so before
choosing Qt plugins means the modules those QML plugins drag in count towards
which Qt plugins the program can use — a QML-only import of QtMultimedia is a
reason to deploy the multimedia plugins, and nothing else would have said so.
For examples/testapp this is the difference between 37 libraries and 79.
examples/deploy/build.sh runs the whole thing and then proves it: the deployed
program is executed with env -i — empty environment, no LD_LIBRARY_PATH, no
QT_PLUGIN_PATH, no PATH — from outside the build tree. If it still reports
its Qt version and SQL drivers, the tree will behave the same way on a machine
that never had this image. examples/testapp goes further and calls into every
bundled library rather than printing its version.
What no tool of this kind can see: a dlopen() at runtime. Nothing in an ELF
header records that.
A bundled OpenSSL reads the host's openssl.cnf
Worth knowing before it bites, because the symptom is quiet and
security-relevant: TLS silently unavailable, QSslSocket::supportsSsl() false,
no error anywhere. examples/qt-smoke prints the SSL line for exactly this
reason.
OpenSSL's config file is found at $OPENSSLDIR/openssl.cnf, and this build uses
the conventional OPENSSLDIR=/etc/ssl so that the system CA certificates in
/etc/ssl/certs are found — which is what you want. The cost is that the
bundled OpenSSL also reads the host distribution's openssl.cnf, a file written
for that distribution's OpenSSL, which is usually patched.
On Fedora 41 this fails. /etc/ssl is a symlink to /etc/pki/tls, and Fedora's
config sets
config_diagnostics = 1
which makes OpenSSL treat configuration problems as fatal rather than
ignoring them. Something in the rest of that file — the crypto-policies include,
the provider section — is not accepted verbatim by a vanilla OpenSSL 3.6.3, and
with diagnostics on, initialisation fails outright. Setting
config_diagnostics = 0 in a copy of the identical file makes it work, which is
how this was pinned down; each individual section passes on its own.
Debian, Ubuntu, Arch, AlmaLinux 9 and Rocky 9 are unaffected — their configs do not turn diagnostics on.
The workaround is one environment variable, set wherever the application is
launched from (an AppImage AppRun, a Flatpak manifest, a systemd unit, a
.desktop file):
OPENSSL_CONF=/opt/myapp/lib/openssl.cnf # or /dev/null to use built-in defaults
The permanent fix is to build OpenSSL with no-autoload-config, so the library
never reads a config file while still finding certificates in /etc/ssl/certs.
That needs an overlay port for openssl and a rebuild of everything linked
against it, and has not been done here.
What the target machine still has to have
Everything self-compiled travels; the graphics, input and font stack does not, because it belongs to the machine. A console program needs nothing beyond glibc and libstdc++. A Qt GUI program needs the host to provide:
| RHEL/Alma/Rocky/Fedora | libglvnd-glx mesa-libGL libxkbcommon-x11 xcb-util-cursor xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil fontconfig dbus-libs |
| Debian/Ubuntu | libgl1 libglx0 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 libxcb-shape0 libfontconfig1 libdbus-1-3 |
| Arch | libglvnd libxkbcommon-x11 xcb-util-cursor xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil fontconfig dbus |
That is the list a .deb or .rpm built from the tree should declare as its
dependencies, and it is what test-portability.sh --gui installs before running
a GUI program — a real target is a desktop, not a 70 MB base image with no
OpenGL in it at all.
A wxWidgets program needs exactly what a Qt one needs and nothing more:
wx here is wxQt, so it deploys the same
libQt6Widgets.so.6 a Qt program does and asks the host for the same GL, xcb
and fontconfig it already asks for. host-requirements.sh answers for whichever
tree was actually produced, which is why it asks the tree rather than a table.
A .NET program adds one more, whatever else it is: ICU — libicu on
RHEL/Alma/Rocky/Fedora and Arch, libicu72/libicu74/… on Debian and Ubuntu.
It belongs in the same Depends line, and it is needed even by a
--self-contained publish, for the reason in
.NET 10.
Build the image
./build-image.sh # code.wisesourcery.com/docker/linuxbuildenv:latest
IMAGE=linuxbuildenv:local ./build-image.sh # somewhere else
./build-image.sh --target libs # stop after the library set
First build compiles the whole library set twice over plus Qt, so budget a couple of hours on 16 cores; afterwards it is a cached layer. The vcpkg binary cache, the Qt tarball, the Qt sources, ccache and the Qt build tree are all BuildKit cache mounts, so an interrupted build resumes rather than restarts, and changing only the manifest rebuilds only what changed.
autoconf is built from source into /usr/local as part of the toolchain
stage. AlmaLinux 9 ships 2.69 and has nothing newer anywhere in AppStream, CRB
or EPEL, while a growing number of autotools projects now declare
AC_PREREQ([2.70]) — and vcpkg re-runs autoreconf rather than trusting a
shipped configure, so the autoconf in the image decides whether a port builds
at all. 2.72 is what Debian trixie gives winbuildenv, which is the version the
two images have to agree on. --build-arg AUTOCONF_VERSION= and
AUTOCONF_SHA256= move it, together.
To pin a different Qt: --build-arg QT_VERSION=6.11.1 (the download URL is
derived from it; override with --build-arg QT_URL=...). To move Node.js, bump
NODE_VERSION and NODE_SHA256 together from
https://nodejs.org/dist/v<version>/SHASUMS256.txt. To move .NET, bump
DOTNET_SDK_VERSION and DOTNET_SDK_SHA512 together from the sdk entry in
https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/10.0/releases.json.
On the build server
.forgejo/workflows/build-image.yml does all of the above on the docker
runner label: it builds through this same build-image.sh, runs
examples/smoke and examples/qt-smoke inside the result, and pushes
:latest alongside a tag naming the commit. A push to dev that touches
anything but Markdown triggers it; Run workflow takes a no_cache input for
the periodic proof that the Dockerfile still builds against today's upstream.
That label is Docker-in-Docker — an agent whose jobs get a daemon of their own
rather than the host's socket. It is a runner instance of its own, because the
privileged it needs is a per-runner setting; dockerbuildenv/README.md in the
buildenv tree has the image and the act_runner configuration.
Use it
docker run --rm -it \
-v "$PWD":/work \
-v ~/plxlib:/opt/plxlib:ro \
code.wisesourcery.com/docker/linuxbuildenv:latest
The shell starts with setvars.sh sourced. There is one of these where the
Windows image has two — a single native target needs no arch switch. It exports
LIBS_ROOT, LIBS_STATIC_ROOT, WISELIBS_ROOT, QT_ROOT, PLXLIB_ROOT,
CMAKE_PREFIX_PATH, CMAKE_TOOLCHAIN_FILE, PKG_CONFIG_PATH and PATH.
PATH covers three places programs actually live, because vcpkg and Qt both
keep them out of bin:
$QT_ROOT/bin, $QT_ROOT/libexec |
qmake, and the code generators — moc, rcc, uic, qmlcachegen, qmlimportscanner. Qt hides libexec on purpose, since CMake finds those through the Qt6*Tools packages; they are on PATH here for hand-rolled builds and scripts. |
$LIBS_ROOT/bin |
shared libraries and the few ports that install to bin. |
$LIBS_ROOT/tools/<port>[/bin] |
everything else a port installs as a program: wxrc, wx-config, glib-compile-resources, gdbus-codegen, pcre2-config, curl-config, uconv. |
That last row is why setvars.sh walks the tree instead of listing it. vcpkg's
tools/ layout is not uniform — some ports drop executables straight into
tools/<port>, others one level down in tools/<port>/bin — and any port with
a debug build keeps a second copy in tools/<port>/debug that must not shadow
the release one. Each candidate directory is taken only if it holds an
executable, which skips the debug/ copies and ICU's config/ (makefile
fragments) without either being named. Add a library and its tools appear on
PATH with no edit here.
Building a CMake project
CMAKE_TOOLCHAIN_FILE is already exported, so:
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
Against the static tree:
cmake -B build-static -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DVCPKG_TARGET_TRIPLET=x64-linux-static
The default toolchain (cmake/wisebuild-vcpkg.cmake) goes through vcpkg's own
buildsystem integration, which matters: several vcpkg-generated package configs
(libsodium's, for one) reference _VCPKG_INSTALLED_DIR and only resolve
correctly that way. It also keeps the sibling triplet off CMAKE_PREFIX_PATH
and PKG_CONFIG_PATH, so a "static" build cannot quietly find the shared set
first.
It selects no compiler — the target is this machine and gcc-toolset-14 is what
PATH resolves to. For a build that must not have vcpkg's integration in the
way, use $WISEBUILD_PLAIN_TOOLCHAIN (cmake/toolchain-linux.cmake).
Debugging
gdb is in the image — AlmaLinux's own 16.3, which reads everything gcc 14
emits — along with gcore, gstack and pstack.
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build
gdb --args build/yourapp --whatever
It needs no setup, and that is the one thing worth saying about it: the library
set and Qt are registered in /etc/ld.so.conf.d rather than exported through
LD_LIBRARY_PATH, so a debuggee started from a shell that never sourced
setvars.sh still finds libssl.so.3 and libQt6Core.so.6. The same is true
of a program run from a wisedeploy staging directory, where the $ORIGIN
rpaths do the work instead.
Debugging the deployed tree is the more interesting case, because that is the tree that will be on somebody else's machine, and it works unchanged:
wisedeploy --appname myapp build/myapp
gdb /opt/myapp/bin/myapp
Unlike the Windows environment there is no cross debugger and no gdbserver
dance — the target is this machine.
Layout
/opt/vcpkg vcpkg, pinned to the commit in the Dockerfile
/opt/wiselibs/<triplet>/ the library set, one tree per triplet
/opt/qt Qt 6.11.2 (shared)
/opt/linuxbuildenv/cmake/ CMake toolchain files
/opt/linuxbuildenv/scripts/ setvars.sh, wisedeploy, check-*.sh, helpers
/opt/linuxbuildenv/vcpkg.json the library manifest (the pre-Qt pass)
/opt/linuxbuildenv/vcpkg-qt/ the post-Qt manifest: wxWidgets
/opt/linuxbuildenv/overlay-ports/ zxing-cpp, libmariadb, wxwidgets
/opt/node Node.js LTS + npm (linked into /usr/local/bin)
/opt/dotnet .NET SDK 10 (linked into /usr/local/bin)
/opt/plxlib bind-mount point for the sanctuarylib SDK
/work your source tree
plxlib is not baked into the image (proprietary SDK). Mount the directory
holding plxlib-linux64/ at /opt/plxlib, or point elsewhere with
PLXLIB_HOME / PLXLIB_ROOT.
/opt/wiselibs/x64-linux-dynamic/lib and /opt/qt/lib are registered in
/etc/ld.so.conf.d rather than exported through LD_LIBRARY_PATH, so anything
built here runs under gdb, under a test runner and out of a staging directory —
none of which inherit a sourced shell.
Examples
examples/smoke |
links a slice of the library set, built both ways, prints the versions |
examples/qt-smoke |
Qt version, TLS backend and SQL drivers |
examples/deploy |
build → wisedeploy → run with env -i → tarball |
examples/testapp |
a whole application, deployed to /opt/test — below |
The sample application
examples/testapp is the worked example of what this image is for: a Qt Quick
program that links every library in the set, deployed as a self-contained tree
at /opt/test.
examples/testapp/build.sh # build, deploy to /opt/test, verify, tar it up
DEST=/opt/other examples/testapp/build.sh
It is a real application with a window, and it is also its own test. Run it
with --selftest and it exercises each bundled library rather than printing its
version — an EVP SHA-256, argon2id and a BLAKE2b, a zlib/lz4/lzma round trip, a
Lua chunk, a pugixml parse, an insert-and-read-back through the QSQLITE driver
plugin, and a QR code encoded by zxing-cpp and decoded again — then exits. That
mode needs no display, which is what makes the tree verifiable inside a stock
container:
./test-portability.sh --gui dist/testapp-linux-x86_64.tar.gz \
env QT_QPA_PLATFORM=offscreen test/bin/testapp --selftest
Version banners are the wrong test for a deployment. A missing .so, a plugin
left behind or an rpath still pointing at the build machine all produce a
program that starts and prints its versions perfectly; what breaks is the code
path that actually calls into the thing. So the sample calls into all of them.
The tree it produces is ~79 libraries, 55 plugins and 51 QML modules, and it
runs unmodified on all eight distributions in test-portability.sh — plus, on a
real desktop, under both the xcb and wayland platform plugins.
--qmldir is not optional for a Qt Quick program. It is the source tree
qmlimportscanner reads to work out which modules under /opt/qt/qml the
application imports. Without it the binary deploys, starts, and dies on the
first import QtQuick with module QtQuick is not installed.
Adding a library
Add it to vcpkg.json — keeping it in step with winbuildenv/vcpkg.json, which
is the point of both files being the same — then rebuild the image, or run
build-libs.sh inside a container (optionally
TRIPLETS="x64-linux-static" build-libs.sh).
If it needs Qt to build, it goes in vcpkg-qt/vcpkg.json instead, and is built
by the same script with the manifest and merge mode named:
MANIFEST_ROOT=/opt/linuxbuildenv/vcpkg-qt MERGE=1 build-libs.sh
Overlay ports and triplets come from $OVERLAY_ROOT, which defaults to
/opt/linuxbuildenv either way, so there is still one of each.
Differences from winbuildenv
| winbuildenv | linuxbuildenv | |
|---|---|---|
| targets | 4 (x86/x64 × static/dynamic) | 2 (static/dynamic) |
| setvars | setvars32.sh, setvars64.sh |
setvars.sh |
| Qt passes | 2 (host + cross) | 1 |
| Qt 32-bit | no | n/a |
| Qt OpenSSL | loaded at runtime | linked |
| deploy tool | windeployqt (a replacement) |
wisedeploy |
| running the output | wine | it just runs |
| debugger | cross gdb + gdbserver.exe | gdb, natively |
| wxWidgets | wxMSW | wxQt (not wxGTK — why) |
| library passes | 1 | 2 (before and after Qt) |
| libpcap capture | needs an Npcap/WinPcap SDK | AF_PACKET, built in |
| ABI concern | which CRT and exception model | which glibc and libstdc++ |