Back to Blog · Software Architecture

AppImage CI for PyQt6 Desktop Apps: From uv sync to a Distributable Artifact

Packaging a Python desktop app as an AppImage means bundling Tesseract OCR, a GGUF ML model, Qt platform libs, and a PyInstaller-built binary into a single distributable file. Here is the exact workflow.

MF
Martin Fournier
· July 05, 2026 · 3 MIN READ
Illustration for: AppImage CI for PyQt6 Desktop Apps: From uv sync to a Distributable Artifact

AppImage CI for PyQt6 Desktop Apps: From uv sync to a Distributable Artifact

Shipping a Python desktop app to Linux users means one thing: AppImage. Not Flatpak, not Snap, not a PyPI package. AppImage is self-contained, runs on any distro from the last decade, and requires zero installation.

The Dependency Problem

A PyQt6 OCR app is not a simple script. The dependency tree:

  • Python 3.12 runtime
  • PyQt6 (Qt6 shared libraries, platform plugins)
  • Tesseract OCR (binary + language data for English and French)
  • A GGUF LLM model (~400 MB, NuExtract 1.5 Tiny quantized)
  • yoyo migrations for SQLite
  • system Qt plugins: libxcb-cursor, libxkbcommon, EGL, OpenGL

A naive pip install and ship approach fails because Qt needs platform plugins at runtime, Tesseract needs to be on PATH, and the GGUF model is downloaded from Hugging Face, not packaged.

The Build Flow

The workflow runs on ubuntu-latest, triggered by a version tag push or manually.

Python environment with uv

pip install uv and uv sync --group dev. uv replaces pip/poetry. It resolves dependencies in sub-second time and caches aggressively. In CI, a full sync from cache is under 10 seconds.

System dependencies

apt-get install tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng. Then libgl1, libegl1, libxkbcommon-x11-0, libxcb-cursor0, and every Qt platform plugin the app might touch at build time. Missing libxcb-cursor crashes the AppImage on first text input. Missing libegl1 breaks hardware-accelerated rendering on Wayland. The list was discovered the hard way: run the AppImage, watch it fail, add the missing lib, rebuild.

appimagetool

Downloaded from the AppImageKit releases page and symlinked into /usr/local/bin. appimagetool takes an AppDir (a standard directory with a .desktop file and an icon) and squashes it into an AppImage. The AppDir must contain everything the app needs at runtime.

The GGUF model

Downloaded during CI and bundled into the AppDir. This avoids shipping a 400 MB blob in the git repository and ensures the CI build is the single source of truth.

PyInstaller + AppImage script

pip install pyinstaller, then run the build script. It runs PyInstaller to produce a single directory with the frozen app, copies in the Tesseract binary and language data, places the GGUF model in the expected path, then runs appimagetool.

Post-Build Verification

A separate smoke test workflow triggers after the build completes. It downloads the AppImage artifact and validates: file is executable (file command confirms ELF/AppImage type), size is above a minimum threshold (prevents broken builds from shipping), extraction via --appimage-extract succeeds.

This caught a failure where PyInstaller silently produced a non-functional binary because a Qt plugin was missing. The AppImage was technically valid but crashed on launch. The smoke test now runs the app with --help inside the extracted AppDir to verify the entry point actually responds.

Versioning and Releases

The version string comes from the git tag or manual dispatch input, normalized to strip the leading v. On tag pushes, a GitHub Release is created automatically with both the AppImage and tar.gz attached. Workflow dispatch allows test builds without polluting the release list.

What an AppImage Cannot Do

AppImage is not a sandbox. It runs with the user's permissions. It cannot auto-update without an external updater mechanism. It does not integrate with the system package manager. These are known trade-offs: the target audience is technical users who prefer a single file they can download and run.

For that use case, AppImage CI is the cleanest path from uv sync to a distributable artifact.