Skip to content

Build recipes

OmniPackage builds each package in a clean per-distro container. Most projects need only build_dependencies filled in (see builds); the patterns below cover the cases that need more — Qt/CMake quirks, Electron apps, and Arch. To match a specific error message to a fix, see Troubleshooting.

CMake and Qt

  • Make install rules unconditional. If a project gates its install() rules behind an AppImage/qmake flag, lift them into an always-on if(UNIX AND NOT APPLE) block so packaging works without that flag. Install the binary, .desktop file, icons, and the AppStream metainfo (upstream often forgets the last one).

  • Exclude bundled-library artifacts with a component. Bundled deps (e.g. Qt-Advanced-Docking-System) ship their own install() rules — headers, a static lib, cmake config — which land in the buildroot and trigger rpm's "Installed (but unpackaged) files" error. Tag your own rules and install only that component:

    # rpm  %install
    DESTDIR=%{buildroot} cmake --install _build --component myapp
    # debian/rules
    DESTDIR=$(CURDIR)/debian/<pkg> cmake --install _build --component myapp
    
  • On deb, drive cmake directly — not dh_auto_configure. dh_auto_configure forces -DFETCHCONTENT_FULLY_DISCONNECTED=ON, which blocks CPM/FetchContent from downloading bundled deps, so the fetched targets never exist and target_link_libraries fails. Drive cmake yourself in debian/rules (recipes are TAB-indented):

    %:
        dh $@
    override_dh_auto_configure:
        cmake -S . -B _build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
    override_dh_auto_build:
        cmake --build _build --parallel
    override_dh_auto_test:
    override_dh_auto_install:
        DESTDIR=$(CURDIR)/debian/{{ package_name }} cmake --install _build --component myapp
    
  • Initialize submodules first. OmniPackage stages the working tree with rsync, so uninitialized submodules ship empty and the build can't find the subproject. Run git submodule update --init --recursive before building — --recursive matters for nested submodules.

  • Relocate hardcoded DESTINATION lib for lib64 distros. A project that installs libs to a literal lib puts them in /usr/lib even on Fedora/openSUSE/EL/Mageia, where the loader looks in /usr/lib64, so at runtime the app can't find its own private libs. Fix in the spec %install (Debian's /usr/lib is correct, so guard on it):

    if [ "%{_libdir}" != "/usr/lib" ] && [ -d %{buildroot}/usr/lib ]; then
      mkdir -p %{buildroot}%{_libdir}; mv %{buildroot}/usr/lib/* %{buildroot}%{_libdir}/; fi
    
  • Nudge deb for private / no-SONAME libs. Internal shared libs with no version (libfoo.so) carry no shlibs entry, so dpkg-shlibdeps reports no dependency information found. Add an override; rpm self-satisfies via basename Provides/Requires:

    override_dh_shlibdeps:
        dh_shlibdeps -l$(DESTROOT)/usr/lib -- --ignore-missing-info
    
  • QML modules and the SVG plugin aren't auto-detected. They're dlopened, not linked, so list them in runtime_dependencies — see the QML map below.

Tip

Working references: mpz and rssguard.

Qt6 dependencies

Package names diverge per distro family — this is the main reason for per-family anchors. For a Qt6/CMake app using Core, Gui, Widgets, Network and PrintSupport (all in qtbase) plus Core5Compat and LinguistTools, and needing GuiPrivate (e.g. for a bundled docking system):

Need Fedora / RHEL openSUSE Debian / Ubuntu
compiler + build tools gcc-c++ cmake make git gcc-c++ cmake make git build-essential cmake git
Qt6 base (Core/Gui/Widgets/Network/PrintSupport) qt6-qtbase-devel qt6-base-devel qt6-base-dev
Qt6 private headers (GuiPrivate) qt6-qtbase-private-devel qt6-base-private-devel qt6-base-private-dev
Core5Compat qt6-qt5compat-devel qt6-qt5compat-devel qt6-5compat-dev
LinguistTools (lrelease) qt6-qttools-devel qt6-tools-devel + qt6-linguist-devel qt6-tools-dev + qt6-l10n-tools
OpenGL dev mesa-libGL-devel Mesa-libGL-devel libgl-dev

On AlmaLinux/Rocky, Qt6 lives in EPEL/CRB, which aren't on the base image and install after build_dependencies. Pull them in before_build_script instead:

el_rpm: &el_rpm
  <<: *common
  build_dependencies: [gcc-c++, cmake, make, git]
  before_build_script: >-
    dnf install -y epel-release &&
    dnf install -y --nobest --enablerepo=crb
    qt6-qtbase-devel qt6-qtbase-private-devel qt6-qttools-devel qt6-qt5compat-devel mesa-libGL-devel
  rpm: { spec_template: ".omnipackage/specfile.spec.liquid" }

Runtime QML modules

A QML app dlopens its imported modules and the SVG imageformat plugin at runtime, so they must be explicit runtime_dependencies:

Import / need Fedora / RHEL openSUSE Debian / Ubuntu
QtQuick, QtQml, Layouts, Shapes, Controls, Dialogs, Templates, Qt.labs.* qt6-qtdeclarative (usually auto-pulled by the linked libQt6Quick6 soname) qt6-declarative-imports (separate package, not pulled by the soname — must list) one qml6-module-* per import: qml6-module-qtquick, -qtquick-controls, -qtquick-dialogs, -qtquick-layouts, -qtquick-shapes, -qtcore, -qt-labs-platform, -qt-labs-qmlmodels, -qtqml-models, -qtqml-workerscript
SVG imageformat plugin (SVG icons) qt6-qtsvg libQt6Svg6 (ships the plugin) qt6-svg-plugins (not libqt6svg6, which is only the lib)

Warning

On openSUSE the whole QtQuick/Controls import tree lives in qt6-declarative-imports, which the linked-soname deps do not pull — omit it and the app starts with module "QtQuick" is not installed. A missing module only shows at runtime, so confirm the real set with the smoke-test in Verifying a built package.

Patching staged source

Sometimes the build needs a source change the repo doesn't have — a missing QML import, an extra install() rule, a stale flag — and you can't or shouldn't commit it. Patch the staged copy at build time: the change lives entirely in .omnipackage/, and the committed tree stays pristine.

Put an idempotent script in .omnipackage/ (guard every edit so re-runs are no-ops) and call it from both formats — the rpm %prep (after %setup) and the deb override_dh_auto_configure (before configure):

# .omnipackage/patch-qml.sh — inject a private-module import that went fully
# private in QtQuick.Controls.impl in Qt 6.11 (harmless on older Qt).
set -eu
for f in $(grep -rlE '\bIconImage\b' src --include='*.qml' 2>/dev/null || true); do
  grep -q 'QtQuick\.Controls\.impl' "$f" || sed -i '/^import QtQuick\.Controls$/a import QtQuick.Controls.impl' "$f"
done
%prep
%setup -q -n {{ source_folder_name }}
sh .omnipackage/patch-qml.sh
override_dh_auto_configure:
    sh .omnipackage/patch-qml.sh
    dh_auto_configure -- -DCMAKE_BUILD_TYPE=Release

.omnipackage/ is part of the staged tree, so the script is present in-container at the source root. The same trick installs an upstream-forgotten file straight from the spec/rules (install -Dm644 foo.metainfo.xml %{buildroot}%{_datadir}/metainfo/…).

Electron and Node

init detects package.json → electron. The whole build runs inside a shared install.sh called from the rpm %install and the deb override_dh_auto_install (the spec %build stays empty). It provisions Node, runs the JS build, runs electron-builder --linux dir, and stages dist_electron/linux-unpacked/ into /opt/<pkg>/, writing the .desktop file, icons, and the /usr/bin/<pkg> launcher itself. Reference: pulsar.

Provision the toolchain in before_build_script (runs in the source root):

# node-gyp needs a modern python3:
mkdir -p /usr/local/bin
for py in /usr/bin/python3.13 /usr/bin/python3.12 /usr/bin/python3.11; do
  [ -x "$py" ] && { ln -sf "$py" /usr/local/bin/python3; break; }
done
# gcc >= 11 where the default is older (needs gcc-NN / g++-NN in build_dependencies):
for v in 15 14 13 12 11; do
  if [ -x "/usr/bin/g++-$v" ]; then
    ln -sf /usr/bin/gcc-$v /usr/local/bin/gcc; ln -sf /usr/bin/gcc-$v /usr/local/bin/cc
    ln -sf /usr/bin/g++-$v /usr/local/bin/g++; ln -sf /usr/bin/g++-$v /usr/local/bin/c++; break
  fi
done
# nvm via curl (NOT wget — see the openSUSE TW gotcha in Troubleshooting):
export NVM_DIR=/nvm PROFILE=/profile; mkdir -p "$NVM_DIR"; touch "$PROFILE"
nvm --version || { curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash; source "$PROFILE"; }

Build and stage in install.sh ($1 = buildroot, $2 = package name):

source /profile
nvm install; corepack enable; nvm use     # Node from .nvmrc, yarn from packageManager
export HUSKY=0                             # husky's git hooks fail (.git is stripped)
yarn install                              # runs electron-builder install-app-deps
yarn <build>
( cd <app> && yarn electron-builder --linux dir --publish never )
mkdir -p "$1/opt/$2"; cp -a <app>/dist_electron/linux-unpacked/. "$1/opt/$2/"
# prune foreign/musl prebuilts — keep only this build's linux+arch:
U="$1/opt/$2/resources/app.asar.unpacked/node_modules"
case "$(uname -m)" in x86_64) drop=arm64;; aarch64) drop=x64;; *) drop=;; esac
find "$U" -depth ! -path '*/src/*' \( -iname '*darwin*' -o -iname '*win32*' -o -iname '*-musl*' \) -exec rm -rf {} +
[ -n "$drop" ] && find "$U" -depth -iname "*linux-$drop*" -exec rm -rf {} +
mkdir -p "$1/usr/bin"; ln -sf "/opt/$2/$2" "$1/usr/bin/$2"   # launcher symlink

The spec carries a few defines so rpm doesn't strip or shlibdep the prebuilt bundle:

%undefine __brp_mangle_shebangs                       # bundled node_modules use versionless shebangs
%define debug_package %{nil}
%define __os_install_post %{nil}                       # don't strip bundled/foreign-arch binaries
%global __requires_exclude ^.+\\.so\\(\\)\\(64bit\\)$   # drop unversioned bundled-soname Requires

The deb rules drive install.sh and disable strip/shlibdeps (TAB-indented):

override_dh_auto_install:
    $(CURDIR)/.omnipackage/install.sh $(CURDIR)/debian/{{ package_name }} {{ package_name }}
override_dh_strip:
override_dh_shlibdeps:

deb runtime deps are explicit (shlibdeps is off) — the Electron desktop libs, with t64 alternations for newer releases: "libgtk-3-0t64 | libgtk-3-0", "libatspi2.0-0t64 | libatspi2.0-0", plus libnotify4 libnss3 libxss1 libxtst6 libuuid1 libsecret-1-0 xdg-utils. rpm auto-detects the versioned system libs.

Note

Toolchain floor: glibc ≥ 2.28 (Node 22 prebuilt) and gcc ≥ 11 (C++20 <source_location>). EL8 (gcc-toolset needs scl enable) and pre-gcc11 Debian/Ubuntu can't build without a hack — drop them.

pacman and Arch

arch and manjaro build the one PKGBUILD.liquid with makepkg (OmniPackage runs it as an unprivileged omnibuild user — nothing to configure). It's a normal PKGBUILD, Liquid-rendered:

pkgname={{ package_name }}
pkgver={{ version }}                  # makepkg forbids `-`; 0.99~master.<ts>.<hash> is fine
pkgrel=1
pkgdesc="{{ description }}"
arch=("$(uname -m)")                  # never hardcode x86_64 — makepkg sources this
url="{{ homepage }}"
{% if runtime_dependencies.size > 0 %}depends=({{ runtime_dependencies | join: ' ' }}){% endif %}
options=('!lto')                      # only if it links prebuilt C/asm (aws-lc-rs, ring)
source=("{{ source_folder_name }}.tar.gz")
sha256sums=('SKIP')                   # local staged tarball — nothing remote to verify

build()   { cd "$srcdir/{{ source_folder_name }}"; <compile>; }
package() { cd "$srcdir/{{ source_folder_name }}"; <install into $pkgdir>; }
  • build_dependencies are Arch names; base-devel (gcc/make/…) is preinstalled. makepkg runs --nodeps, so these — not the PKGBUILD's depends/makedepends — install the toolchain. No before_build_script is needed (rust/go/crystal+shards/python+python-pip/ruby are all in the official repos).
  • install.sh-based types (python/ruby/electron): package() just runs the shared .omnipackage/install.sh "$pkgdir" — no build(), no !lto.
  • Output: <name>-<ver>-<rel>-<arch>.pkg.tar.zst + detached .sig; the signed repo db is <project_slug>.db.tar.gz.

Verifying a built package

A successful build isn't proof the package installs and runs. Build one rpm and one deb, then inspect the contents and the auto-detected dependencies. The build host is often not Debian, so read the .deb with ar+tar rather than dpkg:

# RPM
rpm -qlp pkg.rpm     # files — expect only your paths, no bundled-lib headers/.a/cmake
rpm -qpR pkg.rpm     # Requires — expect auto-detected libs (Qt6, libc, libstdc++, …)
rpm -qip pkg.rpm     # name / version / license / summary

# DEB without dpkg (match the data member's extension: .xz/.zst/.gz)
m=$(ar t pkg.deb | grep '^data.tar')
ar p pkg.deb "$m" | xz -dc | tar -tf -                          # file list
ar p pkg.deb "$(ar t pkg.deb | grep '^control.tar')" | xz -dc | tar -xO ./control   # Depends/Description

# pacman (.pkg.tar.zst — zstd-aware tar, no pacman needed)
tar -xOf pkg.pkg.tar.zst .PKGINFO    # pkgname/pkgver/arch/depends
tar -tf  pkg.pkg.tar.zst             # files (ignore the .PKGINFO/.BUILDINFO/.MTREE dotfiles)

Static inspection misses runtime gaps — a dlopened QML module or plugin that isn't a dependency. For GUI apps, smoke-test the real package in a throwaway container: install it (pulling deps) and run it headless.

# DEB (apt resolves deps from a local file with the leading ./)
podman run --rm -v "$DEB":/p.deb:ro,Z debian:13 bash -c \
  'apt-get update -qq && apt-get install -y ./p.deb && QT_QPA_PLATFORM=offscreen timeout 6 myapp 2>&1 | grep -i "not a type\|not installed" && echo BROKEN || echo OK'

See also Best practices → Test the installed package and omnipackage portal.