Modern app frameworks like Tauri, Electron, and cargo-packager make it straightforward to build desktop applications and CLI binaries into standalone .rpm and .deb files. However, publishing standalone package files creates a distinct distribution problem for Linux users. Without a native package repository (dnf / zypper / apt), end users must manually download and re-install packages for every update. For fast-moving software projects releasing updates daily or multiple times per day, this manual workflow becomes unsustainable for users.
Getting a new application accepted into official Linux distribution repositories (such as Fedora or Debian official package repos) requires significant maintainer overhead: submission reviews, compliance with strict source-build guidelines, unbundling dependencies, and long approval queues. Small teams and independent developers cannot easily maintain official packages across every Linux distribution.
This guide details how to build an automated downstream packaging pipeline using Fedora COPR, Tito, and GitHub Actions. This pattern ingests upstream standalone binary RPMs, extracts and modularizes their components, tags releases, and publishes an updated COPR repository automatically.
The Linux Packaging Bottleneck
When developers package applications with framework tools, the resulting output is often a single standalone .rpm or .deb file uploaded to GitHub Releases or an HTTP download endpoint.
UX Failures of Standalone Binary Releases
- No Automatic System Updates:
dnf updateor system update GUI applications (GNOME Software, KDE Discover) cannot discover new versions. - High Update Friction: Users must re-visit release pages, download new files, and run local installation commands manually. When release cadences reach 1 to 2 releases per day, user update adoption drops significantly.
- Monolithic Dependency Graphs: Framework packagers frequently bundle desktop GUI, CLI tools, and background server daemons into a single heavy package, preventing modular server-only or CLI-only deployments.
Desktop Distribution Alternatives: Flatpak and AetherPak
For desktop applications, Flatpak provides a sandboxed distribution mechanism with automatic updates. However, submitting applications to Flathub involves strict submission requirements and manual reviewer approvals.
For developers seeking automated Flatpak distribution outside Flathub, projects can publish via alternative indexes like AetherPak.
A concrete reference implementation is the Lemonade Tauri App Flatpak repository, which packages Tauri desktop applications into standalone Flatpak bundles that support automatic updates as upstream releases appear.
For server daemons, CLI binaries, and systemd integration, native RPM repositories remain the standard approach for Fedora, RHEL, CentOS Stream, and openSUSE ecosystems.
Architecture of an Automated Repackaging Pipeline
Fedora COPR (originally Cool Other Package Repositories, and now Community Projects) allows maintainers and community contributors to host automatic RPM repositories. By pairing COPR with Tito (a tool for managing RPM package lifecycles in Git) and GitHub Actions, you can transform standalone binary RPM downloads into a fully automated repository pipeline.
COPR Dist-Git and Source Asset Management
A common misconception when repackaging pre-compiled binaries is that large binary files (x86_64-unknown-linux-gnu.rpm) must be committed directly to Git or tracked with Git LFS. In Fedora COPR, large binary source files do not belong in Git history.
Instead, Fedora COPR leverages a dist-git lookaside cache mechanism:
- The Git repository tracks only lightweight text files: the
.specfile, configuration metadata, and asourcesfile containing MD5/SHA256 checksums of binary payload files. - During build execution, COPR's
dist-git-client sourcestool fetches the corresponding binary artifact from the lookaside cache or specified HTTP endpoint before launchingrpmbuildinside the isolated Mock build chroot. - When dynamic downloads occur during Mock build execution, ensure
enable_net: True(ornet_access: true) is configured in COPR project settings. The corresponding setting is a checkbox "Allow internet access".
Committing compiled binary RPMs directly to Git repositories causes rapid repository bloat. Utilizing COPR's dist-git lookaside cache keeps the packaging Git repository lightweight (containing only text spec files and version scripts). Git LFS remains an alternative for self-hosted or air-gapped CI environments where external lookaside storage is unavailable.
Re-building proprietary or complex multi-architecture binaries from source inside standard RPM build environments often requires reproducing complex toolchains. Repackaging pre-compiled upstream binary RPMs via rpm2cpio in the spec %prep phase isolates the distribution mechanism from the build environment while preserving native system integration (systemd service units, desktop menu entry caches, binary symlinks).
Implementation Walkthrough
1. Designing the RPM Spec File
The spec file accepts the pre-fetched upstream binary .rpm file, unpacks its contents using rpm2cpio and cpio, and distributes the files into appropriate package paths and sub-packages using standard RPM macros (%{_bindir}, %{_datadir}, %{_prefix}/lib).
Below is an example spec file (nowledge-mem.spec) supporting four sub-packages: a metapackage (nowledge-mem), a CLI package (nowledge-mem-cli), a GUI package (nowledge-mem-desktop), and a headless daemon package (nowledge-mem-server).
Name: nowledge-mem
Version: 0.10.59
Release: 1%{?dist}
Summary: Personal memory and context management system (Metapackage)
License: LicenseRef-Proprietary
URL: https://download-mem.nowledge.co
Source0: x86_64-unknown-linux-gnu.rpm
ExclusiveArch: x86_64
# AutoReqProv: no prevents RPM build helpers from failing on unbundled internal libraries in pre-compiled binaries
AutoReqProv: no
%global debug_package %{nil}
BuildRequires: cpio
BuildRequires: rpm
BuildRequires: systemd-rpm-macros
Provides: nmem = %{version}-%{release}
Requires: nowledge-mem-cli = %{version}-%{release}
Requires: nowledge-mem-desktop = %{version}-%{release}
Requires: nowledge-mem-server = %{version}-%{release}
%description
Metapackage installing desktop GUI, CLI tools, and local backend server.
%package cli
Summary: Nowledge Mem CLI and TUI tools
AutoReqProv: no
%description cli
Command-line interface (nmem) and Terminal UI (nmem-tui).
%package desktop
Summary: Nowledge Mem Desktop GUI client
AutoReqProv: no
Requires: nowledge-mem-cli = %{version}-%{release}
Requires: gtk3
Requires: (webkit2gtk4.1 or webkit2gtk4.0 or webkit2gtk3)
%description desktop
Desktop GUI client and desktop entry icons.
%package server
Summary: Nowledge Mem backend server daemon
AutoReqProv: no
%{?systemd_requires}
%description server
Headless server daemon with systemd user service units.
%prep
%setup -q -c -T
# Multi-path search for binary payload across local directory, Mock chroot, and SRPM sources
RPM_FILE=""
for f in \
"/sources/build/x86_64-unknown-linux-gnu.rpm" \
"/sources/x86_64-unknown-linux-gnu.rpm" \
"/builddir/build/SOURCES/x86_64-unknown-linux-gnu.rpm" \
"%{_sourcedir}/x86_64-unknown-linux-gnu.rpm" \
"%{SOURCE0}"; do
if [ -f "$f" ] && rpm2cpio "$f" >/dev/null 2>&1; then
RPM_FILE="$f"
break
fi
done
if [ -n "$RPM_FILE" ]; then
echo "Extracting binary payload from: $RPM_FILE"
rpm2cpio "$RPM_FILE" | cpio -idmv
else
echo "ERROR: Upstream binary RPM not found in build paths." >&2
exit 1
fi
%build
# Pre-compiled binary package; no build step required.
%install
rm -rf %{buildroot}
mkdir -p %{buildroot}%{_bindir}
mkdir -p %{buildroot}%{_prefix}/lib
mkdir -p %{buildroot}%{_datadir}/applications
mkdir -p %{buildroot}%{_datadir}/icons
mkdir -p %{buildroot}%{_userunitdir}
cp -a usr/bin/* %{buildroot}%{_bindir}/ 2>/dev/null || true
cp -a "usr/lib/Nowledge Mem" %{buildroot}%{_prefix}/lib/
cp -a usr/share/applications/* %{buildroot}%{_datadir}/applications/ 2>/dev/null || true
cp -a usr/share/icons/* %{buildroot}%{_datadir}/icons/ 2>/dev/null || true
# Install Systemd User Service Unit
cat << 'EOF' > %{buildroot}%{_userunitdir}/nowledge-mem.service
[Unit]
Description=Nowledge Mem Server Daemon (User Service)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/nmem-server
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
EOF
%post desktop
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f %{_datadir}/icons/hicolor 2>/dev/null || true
fi
%post server
%systemd_user_post nowledge-mem.service
%preun server
%systemd_user_preun nowledge-mem.service
%files cli
%{_bindir}/nmem
"%{_prefix}/lib/Nowledge Mem/_up_/rust-backend/nmem"
%files desktop
%{_bindir}/nowledge-mem
"%{_datadir}/applications/Nowledge Mem.desktop"
%{_datadir}/icons/hicolor/*
%files server
%{_bindir}/nmem-server
%{_userunitdir}/nowledge-mem.service
"%{_prefix}/lib/Nowledge Mem/_up_/rust-backend/nmem-server"Splitting a monolithic application into discrete sub-packages (cli, desktop, server) allows headless servers and remote container environments to install nowledge-mem-server without pulling heavy desktop dependencies like gtk3 or webkit2gtk.
2. Upstream Version Resolution & Downloader Script
To automate version checks and pre-fetch the binary payload for Tito SRPM builds, write a Python script (update_version.py) to inspect the upstream redirect URL, update the spec file Version:, reset Release: to 1, and save the binary artifact to x86_64-unknown-linux-gnu.rpm.
#!/usr/bin/env python3
"""
Upstream Version Resolver and Downloader for Repackaging Pipelines.
Note: Adjust regex patterns in resolve_latest_version() to match your project's
specific upstream release endpoint or redirect URL structure.
"""
import argparse
import os
import re
import urllib.request
import sys
from pathlib import Path
VERSION_REDIRECT_URL = "https://download-mem.nowledge.co/download-mem-rpm"
SPEC_FILE_PATH = Path("nowledge-mem.spec")
BINARY_OUTPUT_PATH = Path("x86_64-unknown-linux-gnu.rpm")
def resolve_latest_version():
req = urllib.request.Request(
VERSION_REDIRECT_URL,
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"}
)
with urllib.request.urlopen(req) as resp:
final_url = resp.geturl()
# Adapt regex pattern to match upstream URL scheme (e.g., /app/<version>/ or /releases/v<version>/)
match = re.search(r"/app/([^/]+)/", final_url)
if not match:
raise RuntimeError(f"Could not parse version from URL: {final_url}")
return match.group(1), final_url
def update_spec_version(new_version, is_new=True):
content = SPEC_FILE_PATH.read_text()
updated = re.sub(r"^(Version:\s*)[^\s]+", f"\\g<1>{new_version}", content, flags=re.MULTILINE)
if is_new:
updated = re.sub(r"^(Release:\s*)[^\s]+", r"\g<1>1%{?dist}", updated, flags=re.MULTILINE)
if content != updated:
SPEC_FILE_PATH.write_text(updated)
def download_upstream_rpm(download_url):
print(f"Downloading upstream binary artifact from {download_url}...")
req = urllib.request.Request(
download_url,
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"}
)
with urllib.request.urlopen(req) as resp, open(BINARY_OUTPUT_PATH, "wb") as f:
while chunk := resp.read(8192):
f.write(chunk)
print(f"Saved binary payload to {BINARY_OUTPUT_PATH}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check-only", action="store_true")
args = parser.parse_args()
latest_version, download_url = resolve_latest_version()
current_version = None
if SPEC_FILE_PATH.exists():
match = re.search(r"^Version:\s*([^\s]+)", SPEC_FILE_PATH.read_text(), re.MULTILINE)
if match:
current_version = match.group(1)
is_new = (current_version != latest_version)
if args.check_only:
print(f"is_new={'true' if is_new else 'false'}")
sys.exit(0)
if is_new or not BINARY_OUTPUT_PATH.exists():
update_spec_version(latest_version, is_new=is_new)
download_upstream_rpm(download_url)
if __name__ == "__main__":
main()3. Automated Version Tagging with Tito
Tito simplifies Git-based RPM management by maintaining package versions, generating changelogs, and managing Git tags automatically.
Avoid editing %changelog manually in the spec file when using Tito. Tito manages %changelog during tito tag unless otherwise specified explicitly. Uncommitted manual changes in the working tree will cause tito tag to fail.
Initialize Tito in the project root directory:
tito initTo create a new release tag automatically when the spec file updates:
tito tag --use-version "0.10.59" --accept-auto-changelog4. Continuous Integration Workflow (GitHub Actions)
Configure a GitHub Actions workflow (.github/workflows/check-updates.yml) to poll the upstream endpoint every 4 hours. When a new version is detected, tito tag generates clean Git release tags, while SRPM generation and copr-cli leverage the dist-git source specification.
name: Automated Update Check & Tito Release
on:
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
check-and-release:
runs-on: ubuntu-latest
container:
image: fedora:latest
options: --user root
steps:
- name: Install Dependencies
run: |
dnf install -y git tito python3 copr-cli rpmdevtools
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "*"
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check Upstream Version and Update Spec
id: updater
run: |
python3 update_version.py
- name: Tag Release with Tito
id: tito_release
run: |
if [ -n "$(git status --porcelain nowledge-mem.spec)" ]; then
NEW_VER=$(python3 -c "import re; print(re.search(r'^Version:\s*([^\s]+)', open('nowledge-mem.spec').read(), re.MULTILINE).group(1))")
# Tito automatically stages modified spec files, updates changelog, and creates release commit
tito tag --use-version "${NEW_VER}" --accept-auto-changelog
echo "CHANGED=true" >> $GITHUB_OUTPUT
fi
- name: Push Commits and Tags
if: steps.tito_release.outputs.CHANGED == 'true'
run: |
git push origin main --follow-tags
- name: Dispatch Build to COPR
if: steps.tito_release.outputs.CHANGED == 'true'
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
tito build --srpm --output=build/
SRPM_FILE=$(ls build/*.src.rpm | head -n 1)
copr-cli build user/nowledge-mem "$SRPM_FILE" --nowaitAutomating tito tag inside a containerized GitHub Actions runner ensures consistent release versioning and changelog entries while keeping local developer setup minimal.
Security Model and Trust Caveats
Repackaging third-party binary artifacts introduces security responsibilities that repository maintainers and end users must acknowledge.
When enabling a community COPR repository containing repackaged binary RPMs, end users delegate complete trust to both the upstream project maintainers and the repackaging repository maintainer.
- No Source Build Verification: Repackaging pipelines extract pre-compiled binaries (
rpm2cpio). The COPR builder does not compile source code, meaning COPR build logs only verify extraction and packaging steps, not binary compilation integrity. - Repository Isolation: Users should avoid adding untrusted community COPR repositories to production machines. Always inspect the underlying spec file and GitHub Actions pipeline before executing
sudo dnf copr enable <user>/<repo>.
Summary Command Reference
For users consuming packages published through this pipeline:
# Enable the community COPR repository
sudo dnf copr enable user/nowledge-mem
# Install the full metapackage (Desktop + Server + CLI)
sudo dnf install nowledge-mem
# Or install headless server daemon only
sudo dnf install nowledge-mem-server
# Enable and start the systemd user service
systemctl --user enable --now nowledge-mem.serviceWith this automation in place, project maintainers can publish standalone binaries via their standard framework build tooling, while Linux users retain native dnf update capabilities and systemd integration.
The source code for this work is available on GitHub.



