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.

Diagram

Default User Experience (No Repository)

Upstream Build Pipeline

New Release Published

App Code: Tauri / Electron / Rust

Build Tooling: cargo-packager / electron-builder

Standalone Binary RPM Artifact

User manual download via Browser

Manual shell command: sudo dnf install ./app.rpm

Stale local installation

UX Failures of Standalone Binary Releases

  1. No Automatic System Updates: dnf update or system update GUI applications (GNOME Software, KDE Discover) cannot discover new versions.
  2. 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.
  3. 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.

Self Managed Flatpak Repositories: AetherPak

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 .spec file, configuration metadata, and a sources file containing MD5/SHA256 checksums of binary payload files.
  • During build execution, COPR's dist-git-client sources tool fetches the corresponding binary artifact from the lookaside cache or specified HTTP endpoint before launching rpmbuild inside the isolated Mock build chroot.
  • When dynamic downloads occur during Mock build execution, ensure enable_net: True (or net_access: true) is configured in COPR project settings. The corresponding setting is a checkbox "Allow internet access".
Diagram
End User (DNF)Fedora COPR ServiceTito Release Managerupdate_version.pyUpstream HTTP EndpointGitHub Actions (4h Cron)End User (DNF)Fedora COPR ServiceTito Release Managerupdate_version.pyUpstream HTTP EndpointGitHub Actions (4h Cron)alt[New Version Detected]Run version check & download binary RPM1Query latest download URL / headers2Return version metadata (e.g. 0.10.59)3Download binary RPM artifact (x86_64-unknown-linux-gnu.rpm)4Update Version: tag & reset Release: in spec file5Run tito tag --use-version <version>6Commit changes, generate changelog & Git tag7Push commits & tags to GitHub8Trigger SRPM build via copr-cli9Fetch lookaside source, extract RPM payload, build sub-packages10dnf update (fetches new release automatically)11
Design Rationale (COPR Dist-Git Lookaside Cache vs Git LFS)

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.

Design Rationale (rpm2cpio Repackaging vs Source Compilation)

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).

SPEC
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"
Design Rationale (Sub-package Modularization)

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.

PYTHON
#!/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.

Git Hygiene Rule

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:

BASH
tito init

To create a new release tag automatically when the spec file updates:

BASH
tito tag --use-version "0.10.59" --accept-auto-changelog

4. 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.

YAML
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" --nowait
Design Rationale (Automated Tito Tagging in CI)

Automating 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.

Security Caveat (Third-Party Repositories and Pre-compiled Binaries)

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:

BASH
# 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.service

With 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.

Project Source Code

The source code for this work is available on GitHub.