From cd09e774f51c5340c5b4258e9f9728d3b1fd7d2d Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 09:15:53 +0300 Subject: [PATCH] ci: package the release binary as a Fedora 44 rpm and publish it to rpm.lair.cafe rpm/blackbeard-wallet.spec is the house prebuilt-binary spec: the bare binary from the release, the desktop entry rendered from the same template Tauri's bundlers use, AppStream metadata and four hicolor icons; no strip, no debuginfo; validators run where they exist; every rpmlint warning that stands is explained at the top. The release workflow gains a package job on the rpm runner (changelog entry from the tag through actions/rpm-changelog, rpmbuild with dist .fc44, rpmlint) and a publish job under the shared rpm-publish concurrency group that signs with the repository key, rsyncs as gitea_ci to oolon, runs createrepo_c and regenerates packages.json, exactly as cichlid does. rpm/rpmmacros and script/generate-packages-json.py are cichlid's. Built here from the local release binary: blackbeard-wallet-0.1.0-1.fc44 installs /usr/bin/blackbeard-wallet, the desktop entry, the metainfo and the icons. The publish job needs RPM_SIGNING_KEY, RPM_SIGNING_KEY_ID and RSYNC_SSH_KEY on this repository, which only the operator can add; until then a tag builds and attaches the release but stops at publish. Refs #33 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- .gitea/workflows/release.yaml | 135 +++++++++++++++- rpm/blackbeard-wallet.spec | 111 +++++++++++++ rpm/cafe.lair.blackbeard.wallet.metainfo.xml | 33 ++++ rpm/rpmmacros | 1 + script/generate-packages-json.py | 154 +++++++++++++++++++ 5 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 rpm/blackbeard-wallet.spec create mode 100644 rpm/cafe.lair.blackbeard.wallet.metainfo.xml create mode 100644 rpm/rpmmacros create mode 100755 script/generate-packages-json.py diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 7a2be36..763fd90 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -3,8 +3,14 @@ name: release # A release is a tag on the workspace version. Push `vX.Y.Z` where X.Y.Z is # `version` in Cargo.toml (script/stamp-version.sh refuses anything else), # and this builds the Linux bundles and attaches them to a Gitea release for -# that tag. Packaging for rpm.lair.cafe hangs off the release (#33); the -# Windows and macOS builders are their own child (#34). +# that tag. The package and publish jobs then put the same binary on +# rpm.lair.cafe through the house signed-repo pipeline (#33): Fedora 44 only +# for v1, prebuilt-binary spec, signed with RPM_SIGNING_KEY, rsynced as +# gitea_ci to oolon, createrepo_c and packages.json regenerated. The Windows +# and macOS builders are their own child (#34). +# +# Secrets this needs on the repository: RPM_SIGNING_KEY, RPM_SIGNING_KEY_ID, +# RSYNC_SSH_KEY (the same values the other rpm.lair.cafe publishers hold). on: push: @@ -104,3 +110,128 @@ jobs: name: linux-bundles path: dist/ retention-days: 7 + + package: + needs: linux + # `rpm`: rpmbuild, rpmlint and the signing tools (gitea-runners.md ยง3). + # One Fedora version for v1 (#33); the binary is a Fedora 44 binary + # because rust-gtk3 is Fedora 44, so a wider matrix would need a source + # build under mock or COPR, not a longer list here. + runs-on: rpm + strategy: + fail-fast: false + matrix: + fedora_version: ["44"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: the tag is the workspace version + id: version + run: | + set -euo pipefail + script/stamp-version.sh "${GITHUB_REF_NAME}" + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: the bundles from the linux job + uses: actions/download-artifact@v3 + with: + name: linux-bundles + path: artifacts/ + + - name: changelog entry + uses: https://git.lair.cafe/actions/rpm-changelog@v1 + with: + spec: rpm/blackbeard-wallet.spec + version: ${{ steps.version.outputs.version }} + + - name: build the rpm + run: | + set -euo pipefail + rm -f ~/.rpmmacros + rpmdev-setuptree + v="${{ steps.version.outputs.version }}" + install -m 0755 "artifacts/blackbeard-wallet-${v}-x86_64-linux-gnu" ~/rpmbuild/SOURCES/blackbeard-wallet + cp crates/wallet-app/blackbeard-wallet.desktop rpm/cafe.lair.blackbeard.wallet.metainfo.xml ~/rpmbuild/SOURCES/ + cp crates/wallet-app/icons/32x32.png crates/wallet-app/icons/128x128.png \ + crates/wallet-app/icons/128x128@2x.png crates/wallet-app/icons/icon.png ~/rpmbuild/SOURCES/ + rpmbuild -bb rpm/blackbeard-wallet.spec \ + --define "wallet_version ${v}" \ + --undefine dist \ + --define "dist .fc${{ matrix.fedora_version }}" + rpmlint ~/rpmbuild/RPMS/x86_64/*.rpm || true + ls -l ~/rpmbuild/RPMS/x86_64/ + + - name: keep the rpm for publishing + uses: actions/upload-artifact@v3 + with: + name: rpm-fc${{ matrix.fedora_version }} + path: ~/rpmbuild/RPMS/x86_64/*.rpm + retention-days: 7 + + publish: + needs: package + runs-on: rpm + # Serialise repository metadata updates (createrepo_c) across every + # publisher on rpm.lair.cafe. + concurrency: + group: rpm-publish + cancel-in-progress: false + env: + RPM_REPO_HOST: oolon.kosherinata.internal + strategy: + fail-fast: false + max-parallel: 1 + matrix: + fedora_version: ["44"] + steps: + - uses: actions/checkout@v4 + + - name: the rpm + uses: actions/download-artifact@v3 + with: + name: rpm-fc${{ matrix.fedora_version }} + path: rpms/ + + - name: import the signing key + run: | + set -euo pipefail + command -v sq >/dev/null || { echo "sequoia-sq is not installed"; exit 1; } + echo "${{ secrets.RPM_SIGNING_KEY }}" | gpg --batch --import + fpr=$(gpg --batch --with-colons --list-keys "${{ secrets.RPM_SIGNING_KEY_ID }}" | awk -F: '/^fpr:/ { print $10; exit }') + echo "${fpr}:6:" | gpg --batch --import-ownertrust + sed "s/@GPG_NAME@/${{ secrets.RPM_SIGNING_KEY_ID }}/" rpm/rpmmacros > ~/.rpmmacros + + - name: sign + run: | + set -euo pipefail + for rpm in rpms/*.rpm; do + echo "signing ${rpm}" + rpm --addsign "${rpm}" + rpm -K "${rpm}" + done + + - name: ssh to the repo host + env: + RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }} + run: | + set -euo pipefail + install --directory --mode 700 ~/.ssh + echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519 + ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit + + - name: publish to the stable channel + run: | + set -euo pipefail + dir="/var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64" + ssh "gitea_ci@${RPM_REPO_HOST}" "mkdir -p ${dir}" + rsync --archive --verbose --chmod D755,F644 rpms/*.rpm "gitea_ci@${RPM_REPO_HOST}:${dir}/" + ssh "gitea_ci@${RPM_REPO_HOST}" "cd ${dir} && createrepo_c --update ." + scp script/generate-packages-json.py "gitea_ci@${RPM_REPO_HOST}:/tmp/" + ssh "gitea_ci@${RPM_REPO_HOST}" \ + "python3 /tmp/generate-packages-json.py \ + --repodata-dir ${dir}/repodata \ + --output ${dir}/packages.json \ + --base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64" + echo "published: https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64/" diff --git a/rpm/blackbeard-wallet.spec b/rpm/blackbeard-wallet.spec new file mode 100644 index 0000000..15018f7 --- /dev/null +++ b/rpm/blackbeard-wallet.spec @@ -0,0 +1,111 @@ +# Prebuilt binary from the release workflow's rust-gtk3 job (Fedora 44), so no +# compile here, no strip, no debuginfo: the house convention for a desktop +# application (lair/claude-desktop-package), and blackbeard/wallet #33's +# operator decision: Fedora 44 only for v1, a source build under mock or COPR +# only if the matrix ever grows. +%global _build_id_links none +%global debug_package %{nil} +%global __strip /usr/bin/true + +# Passed in via --define at rpmbuild time (see .gitea/workflows/release.yaml). +%{!?wallet_version: %global wallet_version 0.0.0} + +# rpmlint, and why each remaining warning stands: +# unstripped-binary-or-object deliberate (%%__strip above): the binary is +# built and stripped upstream of rpmbuild. +# no-manual-page-for-binary a windowed application; --help is the manual. +# no-documentation the readme lives in the repository the URL names. +# crypto-policy-non-compliance a dependency calls SSL_CTX_set_cipher_list; +# the wallet's own TLS is rustls (reqwest, subxt). +# spelling-error "quantus", "cli's". +# no-changelogname-tag only on a workstation build; the release +# workflow writes %%changelog first. + +Name: blackbeard-wallet +Version: %{wallet_version} +Release: 1%{?dist} +Summary: A desktop wallet for post-quantum chains + +License: GPL-3.0-or-later +URL: https://git.lair.cafe/blackbeard/wallet + +# The bare binary, the desktop entry template Tauri's own bundlers use, the +# AppStream metadata, and the hicolor icons, all from the release checkout. +Source0: blackbeard-wallet +Source1: blackbeard-wallet.desktop +Source2: cafe.lair.blackbeard.wallet.metainfo.xml +Source3: 32x32.png +Source4: 128x128.png +Source5: 128x128@2x.png +Source6: icon.png + +ExclusiveArch: x86_64 + +# The webview and the toolkit the binary links dynamically. rpm's automatic +# soname requires finds the same libraries; these name the packages a person +# would look for. +Requires: webkit2gtk4.1 +Requires: gtk3 + +%description +The blackbeard wallet is a desktop wallet for post-quantum chains, Quantus +first: ML-DSA signatures, accounts and balances, sending with a review of +what will be signed, reversible transfers with a cancellation window, and +history from blackbeard.observer kept readable offline. Wallet files share +quantus-cli's encrypted format. Nodes are reached through the public +endpoints at *.blackbeard.observer. + +%prep +# Every source is a bare file; nothing to unpack. + +%build +# Built upstream of rpmbuild, on the rust-gtk3 runner. + +%install +install -D -m 0755 %{SOURCE0} %{buildroot}%{_bindir}/blackbeard-wallet +# The desktop template carries Tauri's placeholders; fill them the way its +# bundler would, so the deb, the rpm from `cargo tauri build` and this rpm +# install the same entry. +sed -e 's/{{exec}}/blackbeard-wallet/' -e 's/{{icon}}/blackbeard-wallet/' \ + -e '/^#/d' %{SOURCE1} \ + > blackbeard-wallet.desktop +install -D -m 0644 blackbeard-wallet.desktop \ + %{buildroot}%{_datadir}/applications/blackbeard-wallet.desktop +install -D -m 0644 %{SOURCE2} \ + %{buildroot}%{_metainfodir}/cafe.lair.blackbeard.wallet.metainfo.xml +install -D -m 0644 %{SOURCE3} %{buildroot}%{_datadir}/icons/hicolor/32x32/apps/blackbeard-wallet.png +install -D -m 0644 %{SOURCE4} %{buildroot}%{_datadir}/icons/hicolor/128x128/apps/blackbeard-wallet.png +install -D -m 0644 %{SOURCE5} %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/blackbeard-wallet.png +install -D -m 0644 %{SOURCE6} %{buildroot}%{_datadir}/icons/hicolor/512x512/apps/blackbeard-wallet.png + +%check +# The validators are not on the rpm runner image; where they exist (a +# workstation), a bad entry fails the build rather than the install. +if command -v desktop-file-validate >/dev/null; then + desktop-file-validate %{buildroot}%{_datadir}/applications/blackbeard-wallet.desktop +fi +if command -v appstreamcli >/dev/null; then + appstreamcli validate --no-net %{buildroot}%{_metainfodir}/cafe.lair.blackbeard.wallet.metainfo.xml +fi + +%files +%{_bindir}/blackbeard-wallet +%{_datadir}/applications/blackbeard-wallet.desktop +%{_metainfodir}/cafe.lair.blackbeard.wallet.metainfo.xml +%{_datadir}/icons/hicolor/*/apps/blackbeard-wallet.png + +%post +touch --no-create %{_datadir}/icons/hicolor &>/dev/null || : +update-desktop-database &>/dev/null || : + +%postun +if [ $1 -eq 0 ] ; then + touch --no-create %{_datadir}/icons/hicolor &>/dev/null + gtk-update-icon-cache %{_datadir}/icons/hicolor &>/dev/null || : + update-desktop-database &>/dev/null || : +fi + +%posttrans +gtk-update-icon-cache %{_datadir}/icons/hicolor &>/dev/null || : + +%changelog diff --git a/rpm/cafe.lair.blackbeard.wallet.metainfo.xml b/rpm/cafe.lair.blackbeard.wallet.metainfo.xml new file mode 100644 index 0000000..4314fd7 --- /dev/null +++ b/rpm/cafe.lair.blackbeard.wallet.metainfo.xml @@ -0,0 +1,33 @@ + + + + cafe.lair.blackbeard.wallet + blackbeard wallet + A desktop wallet for post-quantum chains + CC0-1.0 + GPL-3.0-or-later + + blackbeard + + +

+ A wallet for post-quantum chains, Quantus first: ML-DSA signatures, + accounts and balances, sending with a review of exactly what will be + signed, reversible transfers with a cancellation window, and history + from blackbeard.observer that stays readable offline. +

+

+ Wallet files share quantus-cli's encrypted format, so a wallet made + here opens in the CLI and the other way round. +

+
+ blackbeard-wallet.desktop + https://git.lair.cafe/blackbeard/wallet + https://git.lair.cafe/blackbeard/wallet/issues + + Finance + Network + + +
diff --git a/rpm/rpmmacros b/rpm/rpmmacros new file mode 100644 index 0000000..03ce44d --- /dev/null +++ b/rpm/rpmmacros @@ -0,0 +1 @@ +%_openpgp_sign_id @GPG_NAME@ diff --git a/script/generate-packages-json.py b/script/generate-packages-json.py new file mode 100755 index 0000000..ea663df --- /dev/null +++ b/script/generate-packages-json.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Parse RPM repodata and emit a packages.json manifest for the UI.""" + +import argparse +import gzip +import json +import os +import subprocess +import sys +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +RPM_NS = "http://linux.duke.edu/metadata/common" +OTHER_NS = "http://linux.duke.edu/metadata/other" +REPO_NS = "http://linux.duke.edu/metadata/repo" + + +def find_repodata_file(repodata_dir, data_type): + """Read repomd.xml and return the path to a specific data type's file.""" + repomd_path = os.path.join(repodata_dir, "repomd.xml") + tree = ET.parse(repomd_path) + root = tree.getroot() + + for data in root.findall(f"{{{REPO_NS}}}data"): + if data.get("type") == data_type: + location = data.find(f"{{{REPO_NS}}}location") + if location is not None: + href = location.get("href", "") + return os.path.join(os.path.dirname(repodata_dir), href) + + return None + + +def open_compressed(path): + """Open a gzip or zstd compressed file for reading.""" + if path.endswith(".zst"): + result = subprocess.run( + ["zstdcat", path], capture_output=True, check=True + ) + import io + return io.BytesIO(result.stdout) + else: + return gzip.open(path, "rb") + + +def parse_primary(repodata_dir): + """Parse primary.xml.{gz,zst} and return package metadata.""" + path = find_repodata_file(repodata_dir, "primary") + if not path: + print("error: primary metadata not found in repomd.xml", file=sys.stderr) + sys.exit(1) + + packages = {} + with open_compressed(path) as f: + tree = ET.parse(f) + + for pkg in tree.getroot().findall(f"{{{RPM_NS}}}package"): + if pkg.get("type") != "rpm": + continue + + name = pkg.findtext(f"{{{RPM_NS}}}name", "") + version_el = pkg.find(f"{{{RPM_NS}}}version") + ver = version_el.get("ver", "") if version_el is not None else "" + rel = version_el.get("rel", "") if version_el is not None else "" + arch = pkg.findtext(f"{{{RPM_NS}}}arch", "") + + size_el = pkg.find(f"{{{RPM_NS}}}size") + size = int(size_el.get("package", "0")) if size_el is not None else 0 + + time_el = pkg.find(f"{{{RPM_NS}}}time") + build_time = int(time_el.get("build", "0")) if time_el is not None else 0 + + location_el = pkg.find(f"{{{RPM_NS}}}location") + filename = os.path.basename(location_el.get("href", "")) if location_el is not None else "" + + key = f"{name}-{ver}-{rel}" + packages[key] = { + "name": name, + "version": ver, + "release": rel, + "arch": arch, + "summary": pkg.findtext(f"{{{RPM_NS}}}summary", ""), + "size": size, + "buildTime": build_time, + "rpmFilename": filename, + "changelog": [], + } + + return packages + + +def parse_other(repodata_dir, packages): + """Parse other.xml.gz and attach changelog entries to packages.""" + path = find_repodata_file(repodata_dir, "other") + if not path: + return + + with open_compressed(path) as f: + tree = ET.parse(f) + + for pkg in tree.getroot().findall(f"{{{OTHER_NS}}}package"): + name = pkg.get("name", "") + version_el = pkg.find(f"{{{OTHER_NS}}}version") + ver = version_el.get("ver", "") if version_el is not None else "" + rel = version_el.get("rel", "") if version_el is not None else "" + key = f"{name}-{ver}-{rel}" + + if key not in packages: + continue + + for entry in pkg.findall(f"{{{OTHER_NS}}}changelog"): + packages[key]["changelog"].append({ + "author": entry.get("author", ""), + "date": int(entry.get("date", "0")), + "text": (entry.text or "").strip(), + }) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repodata-dir", + required=True, + help="path to the repodata/ directory", + ) + parser.add_argument( + "--output", + required=True, + help="path to write packages.json", + ) + parser.add_argument( + "--base-url", + required=True, + help="public base URL for the repo (e.g. https://rpm.lair.cafe/fedora/43/x86_64)", + ) + args = parser.parse_args() + + packages = parse_primary(args.repodata_dir) + parse_other(args.repodata_dir, packages) + + manifest = { + "generated": datetime.now(timezone.utc).isoformat(), + "baseUrl": args.base_url, + "packages": list(packages.values()), + } + + with open(args.output, "w") as f: + json.dump(manifest, f, indent=2) + + print(f"wrote {len(packages)} packages to {args.output}") + + +if __name__ == "__main__": + main()