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 <