> ## Documentation Index
> Fetch the complete documentation index at: https://cordial.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-032: the AppImage's build base moves to Ubuntu 24.04, and the version floor that blocked it was wrong

**Status:** Accepted
**Date:** 2026-09-13
**Extends:** none
**Related:** [ADR-033](/adr/ADR-033-roblox-versions-are-a-keyed-store)

## Context

The shipped AppImage did not start on a large fraction of hosts. Two independent
defects, found by running `packaging/check-glibc-floor.sh` (written for the
`.rpm`) over every ELF in the built AppDir rather than only Cordial's own two
binaries, which is what both this script's own release-workflow callsite and
`.github/workflows/release.yml` did until now:

**Eighteen bundled libraries carried `GLIBC_2.43` symbol versions.** Among
them `libgtk-4.so.1`, `libglib-2.0.so.0` and `libwebkitgtk-6.0.so.4` — the ones
nothing starts without. glibc is backward compatible and not forward
compatible: a binary built against 2.43 refuses to load against an older
libc, with `version 'GLIBC_2.43' not found`, one line per library, which reads
like a wall of missing dependencies rather than what it is. An AppImage
bundles no libc of its own, so this was not a bug in what Cordial links — it
was the build container's glibc, silently newer than the declared floor.

**Thirteen further libraries were absent outright**, dropped by linuxdeploy's
own excludelist, which assumes a fixed set of libraries is present on every
host it might run on. That assumption is not the same claim as "these are
supplied by the graphics stack and must come from the host or nothing
renders" — the latter is a decision this project makes deliberately, for
`libEGL`, `libGLX`, `libGL`, `libOpenGL`, `libGLdispatch`, `libgbm`, `libdrm`
and `libGLESv2` — and linuxdeploy's list is broader than that and not audited
against Cordial's actual closure. The fix for both defects is the same:
compute the bundle's dependency closure from `ldd`/`objdump` over what is
actually built, rather than trust a tool's built-in idea of what a host has.

## Why the build base sets the glibc floor

The declared floor is glibc 2.39. A binary — or a bundled library — built in a
container whose glibc is newer than that binds against symbol versions the
floor does not have, and nothing after the build can undo that: glibc's own
compatibility guarantee runs one direction only. So the base image is not a
convenience choice like it would be for an ordinary build; it is the actual
mechanism that sets what floor the result can claim. Fedora 44 ships glibc
2.43, four minor versions past the floor, which is exactly what put those
eighteen libraries above it. The base has to be a distribution whose own glibc
is at or below 2.39 — no newer — or the AppImage's stated floor is simply
untrue of what it bundles.

## The version floor that blocked this was eight minor versions too high, and wrong

Fedora had been the base because `gtk4` was pinned at the `v4_20` Cargo
feature. That pin was never a measurement: the comment beside it in
`crates/cordial-shell/Cargo.toml` said GTK 4.10, the pin said 4.20, and neither
was what the code used. Lowering the feature to `v4_10` failed the build at
exactly three call sites — two uses of `CssProvider::load_from_string`, one of
`ToplevelState::SUSPENDED` — all three satisfied by GTK 4.12. `v4_12` builds
the workspace clean, and `libadwaita`'s floor was already 1.5 for `AdwDialog`.
Nothing in the shipped feature set needed 4.20; the pin had simply never been
revisited downward once it was raised to match whatever the newest development
host happened to carry, which is the same mistake this project's own justfile
already warns about for the build environment itself.

That is what made a different base possible at all. At `v4_20`, only a
distribution shipping GTK 4.20 or newer can even compile the workspace, and at
the time of writing that is Fedora 44 and little else — a floor that picks the
build base for you, and picks one four glibc minor versions past where the
AppImage needs to sit. At `v4_12`, the field of usable bases widens to
anything shipping GTK 4.12 or newer, which is most current LTS releases.

## Why Ubuntu 24.04, not the usual answer of 22.04

22.04 is the community's default AppImage base — it is what most published
recipes assume, on the reasoning that an old-enough LTS maximises forward
compatibility. It does not work here: 22.04 ships GTK 4.6, well under the 4.12
the code now needs even after the floor was corrected, and no packaging
workaround changes what a distribution's own repositories carry. Passing over
the obvious choice is worth stating plainly rather than leaving a reader to
wonder why.

24.04 clears every bar at once: GTK 4.14.5, libadwaita 1.5.0, WebKitGTK 6.0
(`libwebkitgtk-6.0-dev` 2.52.6, with 2.44.0 also available in the release
pocket), and glibc 2.39 — not merely under the floor, but *exactly* it, which
is the strongest version of the claim this project can make about where the
AppImage runs.

## WebKitGTK's availability was checked before anything else, per this ADR's own precondition

Moving the base is only useful if the web view still builds. `apt-cache
policy libwebkitgtk-6.0-dev` against `ubuntu:24.04` was run before any other
work on this change, and returned a candidate (`2.52.6-0ubuntu0.24.04.1` in
`noble-updates/universe`, `2.44.0-2` in the base `noble/universe` pocket) —
so the answer was yes, and the rest of this change proceeded. Had it been no,
the right response was to stop and report that, not to drop the webview
feature or vendor a build of WebKitGTK.

One consequence of the switch surfaced during this: the WebKitGTK helper
processes and injected bundle live at a different absolute path on a
Debian-family build than on Fedora's. Fedora's package puts them under
`/usr/libexec/webkitgtk-6.0` (helpers) and `/usr/lib64/webkitgtk-6.0`
(injected bundle); Ubuntu's puts both in one directory,
`/usr/lib/x86_64-linux-gnu/webkitgtk-6.0`, confirmed with `strings` against
the package this base now bundles. `packaging/appimage/build-appimage.sh`'s
own comments had already anticipated this split by path before this change
was made. What has not been re-verified is `AppRun`'s mount-namespace bind,
which still targets the Fedora paths measured on 2026-09-02 against a
Fedora-built library — that measurement does not transfer to a
Ubuntu-built one, and re-measuring it needs a display this pass did not have.
Both files say so at the relevant line rather than silently carrying the stale
claim forward, and a follow-up has been raised to redo the measurement.
None of this affects whether `cordial-shell`/`cordial-run` start and pass
dynamic linking, which is what this ADR's own measurements below test — a
headless container never reaches the point where a `WebProcess` is spawned.

## The excludelist gap needed an actual fix, not just a measurement

The Context section above describes thirteen libraries linuxdeploy drops on
the assumption a host already has them. That was a measurement against the
Fedora build; nothing in `build-appimage.sh` actually closed the gap before
this pass. Building on Ubuntu 24.04 hit it for real: the produced AppImage
failed on plain `ubuntu:24.04` with `cordial-shell: error while loading
shared libraries: libharfbuzz.so.0: cannot open shared object file`, and
linuxdeploy's own log explains why -- `Skipping deployment of blacklisted
library .../libharfbuzz.so.0`. Its excludelist is tuned for a stock desktop,
not the minimal container this AppImage is meant to run on.

`build-appimage.sh` now closes the gap itself: `patchelf --print-needed`
against every bundled ELF, anything missing from the AppDir and not on an
explicit never-bundle list gets copied in from the build host via
`ldconfig -p`, to a fixed point (one library can need a further one
linuxdeploy also skipped). Measured: thirteen libraries completed in one
pass -- `libharfbuzz.so.0` and its own dependants -- a second pass completed
zero.

The first version of that fix filled the closure for `libc.so.6` and
`ld-linux-x86-64.so.2` as well, because both are genuinely `DT_NEEDED` by
`cordial-shell`/`cordial-run` and were genuinely absent. That would have
shipped a worse bug than the one it fixed. `cordial-shell` carries `RUNPATH
$ORIGIN/../lib` (`readelf -d`, checked before packaging), so a bundled
`libc.so.6` in `usr/lib` is found by the dynamic linker's own search order
ahead of the host's -- while the *interpreter* stays the host's regardless,
fixed at link time in `PT_INTERP` and loaded by the kernel before RUNPATH
exists to consult. A mismatched loader/libc pair is the same failure class
the bundled-loader route below was rejected for, reached by RUNPATH instead
of by deliberately rewriting `PT_INTERP`. The never-bundle list now excludes
the whole glibc/loader/compiler-runtime family (`libc`, `libm`, `libdl`,
`libpthread`, `librt`, `libresolv`, `libutil`, `libnsl`, `libanl`, `libcrypt`,
`ld-linux-x86-64.so.2`, `libstdc++`, `libgcc_s`) alongside the graphics stack:
all of them are present on any host with a working dynamic linker at all,
which the glibc-symbol floor already relies on regardless of whether this fix
bundles them.

## Why the bundled-loader route was rejected, despite working

A different fix was prototyped first: bundle a loader (`ld.so`) and its libc
alongside the AppImage's other libraries, and `exec` Cordial's binaries
through it directly, sidestepping the host's glibc entirely rather than
staying under a floor it might not meet. It worked — printed `Cordial 0.13.2
(323afa2)` on `debian:12-slim`, glibc 2.36, well under any floor a Fedora
build could have claimed.

It is still the wrong answer, for a reason specific to this AppImage rather
than to bundled loaders in general. `PT_INTERP` — the string in an ELF binary
naming which interpreter loads it — is baked into each binary at link time,
not supplied at exec time, so a loader shim placed at the `AppRun` boundary
only ever intercepts the *first* process. `cordial-shell` and `cordial-run`
would launch under the bundled loader, but WebKitGTK execs its own helper
processes — `WebKitWebProcess`, `WebKitNetworkProcess`, `WebKitGPUProcess` —
by absolute path, each carrying its own `PT_INTERP` pointing at the host's
interpreter, with no `AppRun` in that path to intercept them. The result is an
AppImage whose top-level smoke test passes completely — the shell starts, the
window draws — while the web view is silently broken underneath it, on a
class of host this fix was specifically meant to reach. That is a worse
failure than the one being fixed: a check that only exercises the outer
binary would call this AppImage good.

## Decision

Build the AppImage in `ubuntu:24.04`, not `registry.fedoraproject.org/fedora:44`.
Keep both crates' `gtk4`/`libadwaita` Cargo features at `v4_12`/`v1_5` — this
ADR is the reason not to raise them back, since doing so re-narrows the usable
base and can silently push it past the floor again the same way `v4_20` did.
Keep `check-glibc-floor.sh` running over the whole AppDir, not just Cordial's
two binaries, in both the build script and the release workflow, as the
standing gate against a base drifting newer than the floor again — that gate
is what caught this in the first place and is cheap to keep running forever.
Keep the closure-completion step in `build-appimage.sh` for the same reason
on the other defect: linuxdeploy's excludelist is not audited against what
this project actually bundles and has no reason to stay accurate as
dependencies change.

Measured against `Cordial-0.13.2-47-g6ff10ca-x86_64.AppImage`: the glibc gate
passes over 149 bundled ELF files; `--diagnostics` prints `Cordial 0.13.2
(6ff10ca)` and exits 0 on both `ubuntu:24.04` (2.39, exactly the floor) and
`fedora:44` (2.43); the same command on `debian:12-slim` (2.36, the control)
refuses with `version 'GLIBC_2.39' not found` and exits 1. The web view is not
covered by any of those four, and was checked separately, below.

## The web view's mount-namespace bind needed the same kind of fix, and only half worked

The section above already flagged that AppRun's bwrap binds still targeted
Fedora's split WebKitGTK layout while this base bundles Ubuntu's merged one.
Checked directly, 2026-09-13, on this host's own live Wayland session with
WebKitGTK, bwrap and xdg-dbus-proxy stripped from the search path: with only
the Fedora binds in place, launching a `WebKitWebView` out of the extracted
AppImage failed exactly as predicted, `Failed to spawn child process
"/usr/lib/x86_64-linux-gnu/webkitgtk-6.0/WebKitNetworkProcess" (No such file
or directory)`.

AppRun now binds that merged path as well, gated on the directory already
existing so a plain `--bind` suffices, the same caution as the existing
`/usr/lib64` case. Re-measured the same way, at the same one-bwrap-layer
depth a real launch uses: the spawn error is gone and `WebKitNetworkProcess`
starts. That is not a working web view yet. The `WebProcess` it hands off to
gets far enough to attempt rendering and aborts, `Could not create default
EGL display: EGL_BAD_PARAMETER`, reproduced with WebKitGTK's own process
sandbox disabled and with software rendering forced, so it is neither this
mount namespace nor GPU acceleration specifically. What it is has not been
established. This dev host has no real `/usr/lib/x86_64-linux-gnu` to test
against — Fedora keeps the split layout — so even the single-bwrap-layer
repro above stands on that directory faked into existence with an extra
bwrap layer wrapped around the outside, purely for the test, and that
scaffolding itself has not been ruled out as the cause. The path-bind defect
this ADR set out to fix is fixed; the web view is a fix and a new, separate,
still-open question, not a fix and a close.

## What this does not do

**It does not touch the `.deb` job's own base.** That job's comment currently
still says Ubuntu 24.04 fails on the `v4_20` floor; the floor has since moved,
which may make that job's own base choice worth revisiting, but that is a
separate change with its own verification and is out of scope here.

**It does not re-verify the web view end-to-end on this base.** See above —
flagged, not silently carried forward, and not blocking the measurements this
ADR is actually about.

## Notes moved from docs/install.md (2026-10-02)

Moved out of the install page, where it was user-facing prose, when that page was cut down to what a reader acts on.

**The web view, and what is still not established.** WebKitGTK does not link the processes that draw a page. It spawns `WebKitWebProcess` and `WebKitNetworkProcess`, loads an injected bundle, and runs `bwrap` and `xdg-dbus-proxy` for its own sandbox: five things reached through absolute paths fixed when WebKitGTK itself was built, `/usr/libexec/webkitgtk-6.0` on Fedora and somewhere different on every other distribution. Up to and including v0.13.0 the AppImage carried copies of them and nothing made WebKitGTK look at the copies, so on a host that had never installed WebKitGTK the sign-in window came up blank and the log said `Failed to spawn child process ".../WebKitNetworkProcess"`. Installing WebKitGTK did not help unless you were on Fedora, because nobody else uses that path.

Cordial now makes those paths resolve to its own copies inside a private mount namespace, which needs `bwrap` and unprivileged overlay mounts. If the kernel or distribution refuses either, the AppImage says so on standard error and carries on without them, and the web view then needs WebKitGTK 6.0 installed at Fedora's path. This has been measured on a stand-in for a machine with no WebKitGTK, but not yet on a real one, and not on any distribution other than Fedora. The Flatpak is unaffected either way.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.