Files
php-qml/docs/packaging-linux.md
magdev 919517c3ce Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL
Closes the two release-prep items called out in the Phase 5 closure
paragraph (a3d35a7).

License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which
keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied
and avoids version-mixing friction with upstream Qt. Two files at the
repo root:

  - LICENSE      — LGPL-3.0 text (the project license).
  - LICENSE.GPL  — GPL-3.0 text the LGPL-3.0 explicitly incorporates
                   ("This version of the GNU Lesser General Public
                   License incorporates the terms and conditions of
                   version 3 of the GNU General Public License…").

framework/php/composer.json: "license": "proprietary" → SPDX
"LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual
license + LICENSE/LICENSE.GPL pointer.

Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>`
in docs/packaging-linux.md) replaced with the real
`src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md,
CHANGELOG.md (compare + tag links), docs/getting-started.md,
docs/packaging-linux.md (build-appimage --update-info example +
latest.json appcast example).

PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later
license shipped, repo URL fixed". Phase 5 closure paragraph rewritten
to record both items resolved (rather than pending).

Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` →
`[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual
`git tag v0.1.0 && git push --tags` itself, which triggers
.gitea/workflows/release.yml. Per the branching memory, releases land
on main — merge dev → main first.

Verified: `make quality` from framework/skeleton green (16 tests, 45
assertions; PHPStan + cs-fixer clean; maker snapshots match).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00

10 KiB
Raw Blame History

Linux packaging

Producing a single-file AppImage for end-users: what make appimage does, how auto-update works, and how the performance smoke gates a release.

macOS (.app + .dmg) and Windows (NSIS / MSIX) are deferred — see PLAN.md §13 Phases 4b/4c.

What an AppImage contains

MyApp-x86_64.AppImage
├── runtime header (AppImage type-2 runtime)
└── SquashFS payload
    └── AppDir/
        ├── AppRun                          → wrapper that exec's usr/bin/<app>
        ├── <app>.desktop
        ├── <app>.png
        ├── usr/
        │   ├── bin/
        │   │   ├── <app>                   the Qt host
        │   │   ├── frankenphp              the bundled PHP runtime
        │   │   └── AppImageUpdate.AppImage sidecar for self-update
        │   ├── lib/
        │   │   ├── x86_64-linux-gnu/Qt6/   Qt LGPL libs (relinkable)
        │   │   └── …                       system libs linuxdeploy pulled in
        │   ├── plugins/                    Qt platform plugins (xcb, offscreen)
        │   └── share/
        │       └── <app>/
        │           ├── symfony/            composer install --no-dev tree
        │           └── Caddyfile           Mercure / port config

BackendConnection's candidate-list resolvers (bundled-mode.md) include ../share/<app>/..., so the AppImage's layout is among the paths it auto-detects.

make appimage

Makefile target in both framework/skeleton/ and examples/todo/:

FRANKENPHP=/path/to/frankenphp make appimage

The target does three things:

  1. Stage a no-dev Symfony tree. Rsync's symfony/ into build/staging-symfony/, rewriting the path-repo URL to absolute (different relative depth than the source tree), then runs composer install --no-dev --classmap-authoritative there.
  2. Invoke packaging/linux/build-appimage.sh. This is the actual AppImage assembly:
    • Downloads linuxdeploy, linuxdeploy-plugin-qt, appimagetool, and AppImageUpdate.AppImage if not already cached under packaging/linux/tools/ (gitignored).
    • Builds an AppDir/ with the layout above.
    • Calls linuxdeploy --plugin qt to pull in Qt + system deps. We pin EXTRA_PLATFORM_PLUGINS=libqoffscreen.so so the AppImage works on headless CI runners and remote desktops.
    • Calls appimagetool separately (we don't use linuxdeploy's appimage-output-plugin because it hung in CI) with a pre-cached --runtime-file and the optional -u <update-info> for auto-update.
  3. Drop Todo-x86_64.AppImage (or whatever you named it) in build/.

End-to-end takes ~6090s on a fast machine. The first run downloads ~50MB of tools.

CLI: build-appimage.sh

If you're packaging an app outside the example tree:

./packaging/linux/build-appimage.sh \
    --app-name myapp \
    --host-binary build/qml/myapp \
    --symfony-dir build/staging-symfony \
    --frankenphp /usr/local/bin/frankenphp \
    --caddyfile Caddyfile \
    --desktop packaging/myapp.desktop \
    --icon packaging/myapp.png \
    --output build/MyApp-x86_64.AppImage \
    --update-info "zsync|https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/MyApp-x86_64.AppImage.zsync"
Flag Required Purpose
--app-name yes AppDir naming, single-instance lock id, BackendConnection candidate path.
--host-binary yes Path to the built Qt host executable.
--symfony-dir yes Tree to package under share/<name>/symfony/. Should be --no-dev already.
--frankenphp yes Path to the FrankenPHP binary to bundle. ~110 MB; dominant in bundle size.
--caddyfile yes Caddyfile that boots Symfony + Mercure on 127.0.0.1:8765.
--desktop yes XDG .desktop file; metadata + icon binding.
--icon yes PNG (256x256 recommended).
--output yes Where to put the resulting .AppImage.
--update-info no Embedded into the AppImage's .upd_info ELF section so AppImageUpdate finds the appcast. Format: zsync|<url-to-.zsync>.

Tools (linuxdeploy*, appimagetool, AppImageUpdate.AppImage) are cached under packaging/linux/tools/. Delete that directory to force a fresh download (e.g. when bumping appimagetool to fix a runtime incompatibility).

Bundled mode

The packaged AppImage runs in bundled mode when launched without BRIDGE_URL. Setting BRIDGE_URL makes it behave like a dev-mode binary — useful for smoke-testing a release candidate against a hand-managed FrankenPHP child.

Per-session bearer tokens, on-demand migrations into ~/.local/share/<app>/var/data.sqlite, and supervisor restarts are all detailed in the bundled-mode doc.

Auto-update

php-qml uses AppImageUpdate — the official self-update tool for the AppImage ecosystem.

How the assembly wires it up

  1. make appimage runs with APPIMAGE_UPDATE_INFO set (CI does this; manual builds can pass it explicitly via --update-info).
  2. appimagetool -u "<update-info>" writes that string into the resulting AppImage's .upd_info ELF section.
  3. The AppImage also bundles AppImageUpdate.AppImage as a sidecar at usr/bin/AppImageUpdate.AppImage.

How the host invokes it

// BackendConnection.cpp
void BackendConnection::checkForUpdates() {
    // …
    m_updateCheck = new QProcess(this);
    m_updateCheck->setProgram(resolveSidecarUpdater());
    m_updateCheck->setArguments({"--check-for-update", appImage});
    // exit code 0 = no update, 1 = update available, anything else = error
}

void BackendConnection::applyUpdate() {
    m_updateApply->setArguments({"--remove-old", appImage});
}

appImage is qgetenv("APPIMAGE") — the AppImage runtime exports this when an AppImage launches. Outside an AppImage the env is unset and both methods short-circuit with updateCheckFailed("APPIMAGE env not set; not running from a packaged AppImage").

Appcast (latest.json)

CI publishes a latest.json next to the release artefacts:

{
  "version": "v0.1.0",
  "released_at": "2026-05-02T10:30:00Z",
  "appimage": {
    "url":    "https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/Todo-x86_64.AppImage",
    "sha256": "…",
    "size":   162831234,
    "zsync":  "https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/Todo-x86_64.AppImage.zsync"
  }
}

AppImageUpdate uses the embedded update-info directly (it doesn't need latest.json); the appcast is there for apps that want to display "what's new" UI without invoking the sidecar. Two QML signals carry the result:

Connections {
    target: BackendConnection
    function onUpdatesAvailable()  { /* show a banner */ }
    function onUpdateApplied()     { /* prompt to restart */ }
}

Why a sidecar instead of linking AppImageUpdate

libappimageupdate exists, but distributing a Qt app linked against it would mean shipping zsync's transitive deps in the same AppImage that wants to replace itself. The sidecar approach hands the replace-in-place dance to a second process so the running AppImage doesn't have to mutate its own SquashFS while it's mounted.

zsync efficiency

The .zsync metadata next to the AppImage lets AppImageUpdate compute a delta against the user's installed copy and download only changed blocks. For a small bug-fix release, a typical update transfers 1020 MB instead of the full ~150 MB.

Performance smoke

examples/todo/tests/perfsmoke.sh runs the built AppImage and asserts PLAN.md §11 Performance budgets:

Budget Default Override env
Bundle size ≤ 200 MB PERF_BUNDLE_MB
Cold start to /healthz 200 ≤ 2 s PERF_COLD_START_MS (CI uses 4 s for shared runners)
Idle RSS (host + descendants) ≤ 200 MB PERF_IDLE_MEM_MB

Run after make appimage:

make perf
# →  bundle size: 158 MB (cap 200 MB)
# →  launching AppImage (xvfb-run -a)
# →  cold start: 1421 ms (budget 2000 ms)
# →  idle memory (host + children): 142 MB (budget 200 MB)
# ✓ perf smoke OK — bundle=158MB cold=1421ms idle=142MB

CI (.gitea/workflows/release.yml) runs this on every v* tag with PERF_COLD_START_MS=4000 (CI runners are slower than bare metal). Failing the smoke fails the release; assets aren't uploaded to the Gitea release until it's green.

The numbers exist for a reason — exceeding them means the app feels sluggish to users. If a real change makes it impossible to stay under, the PR should bump the budget and explain why in the commit message.

Release CI

.gitea/workflows/release.yml runs on v* tags. Roughly:

  1. Setup PHP 8.4, Qt 6.5, FrankenPHP 1.12.2.
  2. make install && make build && make appimage for the todo example.
  3. apt install zsync xvfb.
  4. ./tests/perfsmoke.sh build/Todo-x86_64.AppImage.
  5. zsyncmake Todo-x86_64.AppImage -u Todo-x86_64.AppImage to produce the zsync metadata.
  6. jq an latest.json appcast.
  7. sha256sum everything into SHA256SUMS; optionally GPG-sign with the GPG_KEY secret if it's set.
  8. Curl the Gitea Releases API to create the release and upload Todo-x86_64.AppImage, .zsync, latest.json, SHA256SUMS, SHA256SUMS.asc.

Tagging is the human's job. Per the release-process feedback memory, tagging triggers release.yml; nothing else does. Push the tag, watch the workflow, manually verify the AppImage from a clean machine, then announce.

Bundle size — what dominates

Component Approx
FrankenPHP binary 110 MB
Qt runtime + plugins 25 MB
AppImageUpdate sidecar 8 MB
Symfony app + vendor 310 MB depending on app deps
Host binary + framework 1 MB

FrankenPHP is the dominant cost. A custom FrankenPHP build with only the extensions you use can shave that significantly, at the cost of having to maintain that build. We don't do this in the example.

See also