`BackendConnection`'s candidate-list resolvers ([`bundled-mode.md`](bundled-mode.md#resolving-the-frankenphp-child)) 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/`:
```bash
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`](../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 ~60–90s 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:
| `--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](bundled-mode.md)** 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](bundled-mode.md).
## Auto-update
php-qml uses [AppImageUpdate](https://github.com/AppImage/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`.
`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:
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:
```qml
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 10–20 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*](../PLAN.md#11-performance-and-startup-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) |
# ✓ 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.
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](../CHANGELOG.md), tagging triggers `release.yml`; nothing else does. Push the tag, watch the workflow, manually verify the AppImage from a clean machine, then announce.
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
- [Bundled mode](bundled-mode.md) — what runs inside the AppImage at launch.
- [Configuration](configuration.md) — env vars the AppImage understands.
- [PLAN.md §13 Phase 4a](../PLAN.md#13-roadmap-to-poc) — design rationale and trade-offs.