From 8d510b8ffd7fbe7d84ce9b640f3cdeb8f1b162be Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Tue, 13 Oct 2026 22:05:17 -0500 Subject: [PATCH] Publish desktop releases to a self-hosted dnf repo scripts/rpm-publish.sh builds the RPM, signs it and the repo metadata with a dedicated GPG key, and adds it to a dnf repo in a given directory, keeping the newest three versions for downgrades. The directory holds nothing secret, so it can be served as is. Both publish scripts now share the version scheme in scripts/lib/release-version.sh: .., plain enough for RPM, MSI and semver alike. Android's versionCode stays the commit count. Fixes the publish scripts failing under set -u when the repo URL variable isn't set, which is every run after the first. tauri.conf.json now declares the license and homepage for the package metadata. --- README.md | 52 ++++++++- package.json | 3 +- scripts/fdroid-publish.sh | 11 +- scripts/lib/release-version.sh | 18 +++ scripts/rpm-publish.sh | 201 +++++++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 + 6 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 scripts/lib/release-version.sh create mode 100755 scripts/rpm-publish.sh diff --git a/README.md b/README.md index 4082381..eae2baa 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,53 @@ Notes: - The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release build take a few minutes but keep the binary small. +### Distributing to Fedora (dnf repo) + +Fedora machines get the app, and its updates, from a small dnf repo you host +yourself. `scripts/rpm-publish.sh` builds a signed RPM and adds it to the repo +in a directory you choose. It needs: + +```bash +sudo dnf install rpm-sign createrepo_c +``` + +The first run also makes the package signing key. Give it the address the +directory will be served at: + +```bash +RPM_REPO_URL=https://rpm.example.com/mtgc ./scripts/rpm-publish.sh --init-key ~/rpm-mtgc +``` + +After that, publishing is just: + +```bash +./scripts/rpm-publish.sh ~/rpm-mtgc # or: npm run rpm:publish -- ~/rpm-mtgc +``` + +Then, on each machine, add the repo once and install: + +```bash +sudo dnf config-manager addrepo --from-repofile=https://rpm.example.com/mtgc/mtgc.repo +``` + +```bash +sudo dnf install mtgc +``` + +From then on, `sudo dnf upgrade` picks up new releases. + +- **Serve the whole directory.** Unlike the F-Droid repo, nothing in it is + secret. It holds the RPMs, the signed `repodata/`, the public key + (`RPM-GPG-KEY-mtgc`) and `mtgc.repo`. The newest three versions are kept, so + a bad release can be undone with `sudo dnf downgrade mtgc`. +- **Back up the signing key**, `~/.local/share/mtgc/gnupg`. It has no + passphrase, so publishing runs unattended. Without it, each machine has to + trust a new key before it takes another update. +- Versions work as on Android: `0.1.`, see + [Distributing to phones](#distributing-to-phones-f-droid). +- The RPM is built against this machine's libraries, so it installs on this + Fedora release and newer. + ## Using it - **Add Cards** — type a name (typos OK); ↑/↓ then Enter adds a copy. @@ -401,8 +448,9 @@ Repositories**. installs an update signed with the same key as the installed app, so without the app key every phone would have to uninstall and reinstall. - **Versions are automatic.** The Android versionCode is the number of commits - on `HEAD`, and the version shown is e.g. `0.1.0-44-gece21c1`, so any commit - can ship without a bump. That's also why pushed history shouldn't be + on `HEAD`, and the version shown is `0.1.`, e.g. `0.1.47`, so + any commit can ship without a bump. The major and minor come from + `tauri.conf.json`. That's also why pushed history shouldn't be rewritten. The script won't publish uncommitted changes without `--allow-dirty`, or a versionCode the repo already has. - A phone with a debug build, or anything signed with another key, has to diff --git a/package.json b/package.json index 0a82b90..28b6f80 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "tauri": "tauri", "sync-server:build": "scripts/sync-server-image.sh build", "sync-server:push": "scripts/sync-server-image.sh push", - "android:publish": "scripts/fdroid-publish.sh" + "android:publish": "scripts/fdroid-publish.sh", + "rpm:publish": "scripts/rpm-publish.sh" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/fdroid-publish.sh b/scripts/fdroid-publish.sh index f053c0f..97ee03b 100755 --- a/scripts/fdroid-publish.sh +++ b/scripts/fdroid-publish.sh @@ -108,10 +108,10 @@ fi # The versionCode is the commit count, so every commit on main can ship with no # manual bump and F-Droid sees each one as newer than the last. -version_code="$(git rev-list --count HEAD)" -base_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" -version_name="$base_version-$version_code-g$(git rev-parse --short HEAD)" -if [ -n "$(git status --porcelain --untracked-files=no)" ]; then +source scripts/lib/release-version.sh +version_code="$release_count" +version_name="$release_version" +if $release_dirty; then if ! $allow_dirty; then err "the working tree has uncommitted changes; commit them or pass --allow-dirty" exit 1 @@ -121,7 +121,8 @@ fi # --- 4. The repo, the first time -------------------------------------------- -repo_url="${FDROID_REPO_URL%/}" +repo_url="${FDROID_REPO_URL:-}" +repo_url="${repo_url%/}" case "$repo_url" in "" | */repo) ;; *) err "FDROID_REPO_URL must end in /repo (fdroidserver requires it), e.g. $repo_url/repo"; exit 1 ;; diff --git a/scripts/lib/release-version.sh b/scripts/lib/release-version.sh new file mode 100644 index 0000000..dbc0e16 --- /dev/null +++ b/scripts/lib/release-version.sh @@ -0,0 +1,18 @@ +# release-version.sh — the version every published build carries. +# +# SOURCE this from the repo root. It sets: +# +# release_count commits on HEAD; only ever goes up on main +# release_version .., e.g. 0.1.47 +# release_dirty true when tracked files have uncommitted changes +# +# Major and minor come from tauri.conf.json; the patch is the commit count, so +# any commit can ship without a manual bump, and the number is plain enough for +# every package format (Android versionCode, RPM, MSI, semver). + +release_count="$(git rev-list --count HEAD)" +release_version="$(node -p "require('./src-tauri/tauri.conf.json').version.split('.').slice(0, 2).join('.')").$release_count" +release_dirty=false +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + release_dirty=true +fi diff --git a/scripts/rpm-publish.sh b/scripts/rpm-publish.sh new file mode 100755 index 0000000..d3c58f4 --- /dev/null +++ b/scripts/rpm-publish.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# +# rpm-publish.sh — build a signed RPM of the desktop app and add it to a dnf repo. +# +# Usage: +# RPM_REPO_URL=https://rpm.example.com/mtgc \ +# ./scripts/rpm-publish.sh --init-key ~/rpm-mtgc # first time +# ./scripts/rpm-publish.sh ~/rpm-mtgc # every release after +# +# Or through npm: `npm run rpm:publish -- ~/rpm-mtgc`. +# +# Options: +# --init-key generate the package signing key (once, ever) +# --allow-dirty publish uncommitted changes; the RPM release becomes 0.dirty +# +# Environment: +# MTGC_RPM_DIR repo directory, if not given as an argument +# RPM_REPO_URL public address of the repo directory; needed the first time, +# and changes the address in mtgc.repo if set later +# MTGC_RPM_GNUPGHOME where --init-key keeps the key (default ~/.local/share/mtgc/gnupg) +# +# The whole repo directory is public — the signing key lives elsewhere — so +# serve it as is. Needs rpm-sign and createrepo_c (`sudo dnf install rpm-sign +# createrepo_c`) and the desktop setup from ./scripts/devel-setup.sh. +# +set -euo pipefail + +usage() { + sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; } + +PACKAGE="mtgc" +GNUPGHOME="${MTGC_RPM_GNUPGHOME:-$HOME/.local/share/mtgc/gnupg}" +KEEP=3 # versions kept in the repo, so a bad release can be downgraded from + +init_key=false +allow_dirty=false +dir="${MTGC_RPM_DIR:-}" +while [ $# -gt 0 ]; do + case "$1" in + --init-key) init_key=true ;; + --allow-dirty) allow_dirty=true ;; + -h | --help) usage ;; + -*) usage 1 ;; + *) dir="$1" ;; + esac + shift +done +[ -n "$dir" ] || usage 1 +mkdir -p "$dir" +dir="$(cd "$dir" && pwd)" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +# --- 1. Tools --------------------------------------------------------------- + +for tool in rpmsign createrepo_c gpg; do + if ! command -v "$tool" >/dev/null 2>&1; then + err "$tool not found. Install them: sudo dnf install rpm-sign createrepo_c" + exit 1 + fi +done + +# --- 2. Package signing key ------------------------------------------------- +# +# A key of its own in its own keyring, with no passphrase so publishing runs +# unattended. Every machine trusts it once, when it adds the repo; lose it and +# each one has to import a new key before it takes another update. + +gpg_() { gpg --homedir "$GNUPGHOME" --batch "$@"; } +key_fpr() { gpg_ --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/ { print $10; exit }'; } + +if $init_key; then + if [ -n "$(key_fpr)" ]; then + err "a signing key already exists in $GNUPGHOME; not making another" + exit 1 + fi + info "generating the package signing key in $GNUPGHOME" + mkdir -p "$GNUPGHOME" + chmod 700 "$GNUPGHOME" + gpg_ --pinentry-mode loopback --passphrase '' \ + --quick-gen-key "mtgc packages" rsa4096 sign never 2>/dev/null + warn "Back up $GNUPGHOME." + warn "Without it, every machine has to trust a new key before it takes another update." +fi +fpr="$(key_fpr)" +if [ -z "$fpr" ]; then + err "no package signing key in $GNUPGHOME. Run once with --init-key." + exit 1 +fi + +# --- 3. Version ------------------------------------------------------------- + +source scripts/lib/release-version.sh +release=1 +if $release_dirty; then + if ! $allow_dirty; then + err "the working tree has uncommitted changes; commit them or pass --allow-dirty" + exit 1 + fi + release=0.dirty +fi + +# --- 4. The repo's address -------------------------------------------------- + +repo_url="${RPM_REPO_URL:-}" +repo_url="${repo_url%/}" +case "$repo_url" in + "" | http://* | https://*) ;; + *) err "RPM_REPO_URL must be an http(s):// address"; exit 1 ;; +esac +if [ -z "$repo_url" ] && [ ! -f "$dir/$PACKAGE.repo" ]; then + err "no repo in $dir yet; set RPM_REPO_URL to the address $dir will be served at" + exit 1 +fi +if [ -n "$repo_url" ]; then + cat >"$dir/$PACKAGE.repo" <"$dir/RPM-GPG-KEY-$PACKAGE" + +# --- 5. Only ever move forward ---------------------------------------------- + +newest=0 +for rpm_file in "$dir/$PACKAGE"-*.rpm; do + [ -e "$rpm_file" ] || continue + patch="$(rpm -qp --qf '%{VERSION}' "$rpm_file" 2>/dev/null)" + patch="${patch##*.}" + [ "$patch" -gt "$newest" ] && newest="$patch" +done +if [ "$release_count" -le "$newest" ]; then + err "version $release_version is not newer than the repo's x.y.$newest; commit something first" + exit 1 +fi + +# --- 6. Build and sign ------------------------------------------------------ + +info "building $release_version-$release" +outputs="target/release/bundle/rpm" +rm -rf "$outputs" +cargo tauri build --bundles rpm --ci \ + --config "{\"version\":\"$release_version\",\"bundle\":{\"linux\":{\"rpm\":{\"release\":\"$release\"}}}}" + +built="$(find "$outputs" -name '*.rpm' | head -1)" +if [ -z "$built" ]; then + err "the build produced no RPM under $outputs" + exit 1 +fi +rpmsign --addsign --define "_gpg_path $GNUPGHOME" --define "_gpg_name $fpr" "$built" >/dev/null + +# --- 7. Publish ------------------------------------------------------------- + +update_metadata() { + createrepo_c --quiet --update "$dir" && + gpg_ --yes --local-user "$fpr" --armor --detach-sign "$dir/repodata/repomd.xml" +} + +published="$dir/$(basename "$built")" +cp "$built" "$published" +info "updating the repo metadata" +if ! update_metadata; then + # Take the RPM back out, or the next run would think it's already published. + rm -f "$published" + update_metadata || true + err "updating the repo metadata failed; nothing was published" + exit 1 +fi + +# Drop all but the newest few versions. +mapfile -t old < <(find "$dir" -maxdepth 1 -name "$PACKAGE-*.rpm" | sort -V | head -n -"$KEEP") +if [ "${#old[@]}" -gt 0 ]; then + rm -f "${old[@]}" + update_metadata +fi + +# --- 8. How to use it ------------------------------------------------------- + +info "published $release_version-$release" +repo_file_url="$(sed -n 's/^baseurl=//p' "$dir/$PACKAGE.repo")/$PACKAGE.repo" +cat <