From 4060cca3f679c7a4b69c91b0e819c3951c6a2c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:53:44 +0000 Subject: [PATCH 1/4] build(rpm): add scripts for a signed dnf repository - plan.sh decides per run whether to build and whether to publish for real or as a dry run with a throwaway key - make-srpm.sh packages a release tag, or HEAD for a dry run, as source RPMs of nm-sshuttle and nm-sshuttle-gnome - publish.sh signs and files the packages, restores the published ones, prunes old versions and signs the createrepo_c metadata - smoke-test.sh installs both packages from the assembled site - nm-sshuttle-release carries the .repo file and the public key - scripts/signing-key.sh creates and rotates the signing key and stores it in an environment restricted to main Co-Authored-By: Claude Sonnet 5.5 --- packaging/README.md | 57 +++++ packaging/fedora-releases | 3 + packaging/make-srpm.sh | 49 +++++ packaging/nm-sshuttle-release/build.sh | 20 ++ .../nm-sshuttle-release.spec | 35 +++ .../nm-sshuttle-release/nm-sshuttle.repo | 17 ++ packaging/plan.sh | 146 ++++++++++++ packaging/prune-repo.py | 99 +++++++++ packaging/publish.sh | 179 +++++++++++++++ packaging/site-index.py | 110 ++++++++++ packaging/site/index.html | 40 ++++ packaging/smoke-test.sh | 30 +++ scripts/signing-key.sh | 207 ++++++++++++++++++ 13 files changed, 992 insertions(+) create mode 100644 packaging/README.md create mode 100644 packaging/fedora-releases create mode 100755 packaging/make-srpm.sh create mode 100755 packaging/nm-sshuttle-release/build.sh create mode 100644 packaging/nm-sshuttle-release/nm-sshuttle-release.spec create mode 100644 packaging/nm-sshuttle-release/nm-sshuttle.repo create mode 100755 packaging/plan.sh create mode 100755 packaging/prune-repo.py create mode 100755 packaging/publish.sh create mode 100755 packaging/site-index.py create mode 100644 packaging/site/index.html create mode 100755 packaging/smoke-test.sh create mode 100755 scripts/signing-key.sh diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..73ffd88 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,57 @@ +# Packaging + +- [`rpm/`](rpm) holds the specs of `nm-sshuttle` (noarch) and + `nm-sshuttle-gnome` (the editor for GNOME Settings, architecture-specific). + The `rpm` job of the CI workflow builds and installs them for Fedora 43 and + 44 on every change. +- `../debian/` builds the Debian and Ubuntu packages. + +## The dnf repository + + is a signed dnf repository on +GitHub Pages, for the Fedora releases listed in [`fedora-releases`](fedora-releases). +The [packages workflow](../.github/workflows/packages.yaml) builds a release +tag from source (`make-srpm.sh`, then one `rpmbuild --rebuild` per Fedora +release), signs the packages and the repository metadata (`publish.sh`), +installs from the assembled site in a Fedora container (`smoke-test.sh`) +and only then deploys it. The site is the repository's only state: each run +restores the published packages first, adds the new ones and prunes all but +the newest two versions (`prune-repo.py`). The +[`nm-sshuttle-release`](nm-sshuttle-release) package carries the `.repo` +file and the public key, so that users get both with one `dnf install`. + +Pull requests that change this directory, and runs before the first tag or +before the signing key exist, assemble and smoke-test the same repository +with a throwaway key and deploy nothing. + +### Setting it up + +1. Run `scripts/signing-key.sh` on a trusted machine, from a checkout, with + the `gh` CLI signed in as an administrator of the repository. It creates + the `rpm-signing` environment, restricts it to `main`, stores the new + secret key as its secret `RPM_SIGNING_KEY` and writes the public key to + `packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle`. Commit and push + that file to `main`. Move the backup the script prints out of the machine. +2. In the repository's settings, under Pages, set the source to GitHub + Actions. + +### Releasing + +Push an annotated tag whose version is the release's: + +```console +$ git tag -a v0.1.0 -m 'nm-sshuttle 0.1.0' +$ git push origin v0.1.0 +``` + +The [release workflow](../.github/workflows/release.yaml) starts the packages +workflow on `main`, the only branch that may use the signing key. The tag's +source is packaged under its version, whatever the specs in the tree say. +Tags with a hyphen (`v0.2.0-rc.1`) are not packaged. A change to a package +that is already published needs a new `Release:` in its spec, or a new +version: published packages are never replaced. Run the packages workflow by +hand to rebuild a tag (`tag`, `rebuild`), or, after a key compromise, to +publish from scratch (`discard-published`). + +Rotating the key takes three steps, described at the top of +`scripts/signing-key.sh`. diff --git a/packaging/fedora-releases b/packaging/fedora-releases new file mode 100644 index 0000000..6d2bd4f --- /dev/null +++ b/packaging/fedora-releases @@ -0,0 +1,3 @@ +# Fedora releases to build packages for, one per line. +43 +44 diff --git a/packaging/make-srpm.sh b/packaging/make-srpm.sh new file mode 100755 index 0000000..da95ba1 --- /dev/null +++ b/packaging/make-srpm.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Build the source RPMs of nm-sshuttle and nm-sshuttle-gnome. +# +# Usage: make-srpm.sh [] +# +# With a release tag, vX.Y.Z, it packages that tag's source under version +# X.Y.Z. Without one, it packages HEAD under the version of the spec. The +# specs are the ones of this checkout either way, so a fix to them applies +# to a rebuild of an older tag too. It runs `rpmbuild -bs` without a dist tag, +# so one SRPM serves every Fedora release; the rpm jobs turn it into the SRPM +# that they publish, one per release. Needs git and rpmbuild, and runs in a +# checkout with the tag. +set -euo pipefail + +outdir=${1:?usage: make-srpm.sh []} +tag=${2:-} + +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel) + +if [ -n "$tag" ]; then + [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid release tag: $tag" >&2; exit 1; } + version=${tag#v} + ref=refs/tags/$tag + git -C "$root" rev-parse --verify --quiet "$ref^{commit}" > /dev/null || + { echo "no tag $tag in this checkout" >&2; exit 1; } +else + version=$(sed -n 's/^Version: *//p' "$here/rpm/nm-sshuttle.spec") + ref=HEAD +fi + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir "$work/sources" + +git -C "$root" archive --format=tar.gz --prefix="nm-sshuttle-$version/" \ + -o "$work/sources/nm-sshuttle-$version.tar.gz" "$ref" + +mkdir -p "$outdir" +for spec in "$here"/rpm/*.spec; do + sed "s/^Version: .*/Version: $version/" "$spec" > "$work/sources/${spec##*/}" + rpmbuild -bs --nodeps \ + --define "_sourcedir $work/sources" \ + --define "_srcrpmdir $outdir" \ + --define "dist %{nil}" \ + "$work/sources/${spec##*/}" +done diff --git a/packaging/nm-sshuttle-release/build.sh b/packaging/nm-sshuttle-release/build.sh new file mode 100755 index 0000000..7f7cbc0 --- /dev/null +++ b/packaging/nm-sshuttle-release/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Build the nm-sshuttle-release RPM (no dist tag; it serves every Fedora release). +# +# Usage: build.sh +set -euo pipefail + +outdir=${1:?usage: build.sh } +here=$(cd "$(dirname "$0")" && pwd) +topdir=$(mktemp -d) +trap 'rm -rf "$topdir"' EXIT + +rpmbuild -bb --quiet \ + --define "_topdir $topdir" \ + --define "_sourcedir $here" \ + --define "dist %{nil}" \ + "$here/nm-sshuttle-release.spec" +mkdir -p "$outdir" +cp "$topdir"/RPMS/noarch/nm-sshuttle-release-*.rpm "$outdir/" diff --git a/packaging/nm-sshuttle-release/nm-sshuttle-release.spec b/packaging/nm-sshuttle-release/nm-sshuttle-release.spec new file mode 100644 index 0000000..8a7add2 --- /dev/null +++ b/packaging/nm-sshuttle-release/nm-sshuttle-release.spec @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: MIT + +Name: nm-sshuttle-release +# Bump Version when nm-sshuttle.repo or the signing key changes. +Version: 1 +Release: 1 +Summary: dnf repository configuration for nm-sshuttle's Fedora packages + +License: MIT +URL: https://github.com/dennisklein/nm-sshuttle +Source0: nm-sshuttle.repo +Source1: RPM-GPG-KEY-nm-sshuttle + +BuildArch: noarch + +%description +Configures the dnf repository at https://dennisklein.github.io/nm-sshuttle/, +which serves the nm-sshuttle packages signed by the nm-sshuttle project, and +installs the OpenPGP key that verifies its packages and metadata. + +%prep + +%build + +%install +install -Dpm 0644 %{SOURCE0} %{buildroot}%{_sysconfdir}/yum.repos.d/nm-sshuttle.repo +install -Dpm 0644 %{SOURCE1} %{buildroot}%{_sysconfdir}/pki/rpm-gpg/RPM-GPG-KEY-nm-sshuttle + +%files +%config(noreplace) %{_sysconfdir}/yum.repos.d/nm-sshuttle.repo +%{_sysconfdir}/pki/rpm-gpg/RPM-GPG-KEY-nm-sshuttle + +%changelog +* Sun Oct 11 2026 Dennis Klein - 1-1 +- Initial package diff --git a/packaging/nm-sshuttle-release/nm-sshuttle.repo b/packaging/nm-sshuttle-release/nm-sshuttle.repo new file mode 100644 index 0000000..9bf7c16 --- /dev/null +++ b/packaging/nm-sshuttle-release/nm-sshuttle.repo @@ -0,0 +1,17 @@ +[nm-sshuttle] +name=nm-sshuttle - Fedora $releasever - $basearch +baseurl=https://dennisklein.github.io/nm-sshuttle/rpm/fedora/$releasever/$basearch/ +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-nm-sshuttle +metadata_expire=6h + +[nm-sshuttle-source] +name=nm-sshuttle - Fedora - Source +baseurl=https://dennisklein.github.io/nm-sshuttle/rpm/fedora/source/ +enabled=0 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-nm-sshuttle +metadata_expire=6h diff --git a/packaging/plan.sh b/packaging/plan.sh new file mode 100755 index 0000000..5a5ef74 --- /dev/null +++ b/packaging/plan.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Decide what the packages workflow builds and publishes. +# +# Env: PAGES_URL published repository site +# INPUT_TAG release tag to package; empty means the newest +# vX.Y.Z tag of the checkout +# REBUILD "true" rebuilds even if the packages are already +# published or the repository would not keep the release +# GITHUB_REF only refs/heads/main may publish +# GITHUB_EVENT_NAME "push" republishes the site even if nothing needs +# building, so that changes to it go live +# KEEP_VERSIONS versions kept per package (default 2); a release that +# publish.sh would prune is not built +# Writes tag, build (true|false), fedora (JSON list) and publish +# (real|dry-run|none) to $GITHUB_OUTPUT (stdout when unset). +# +# A real publish packages a release tag. A dry run assembles the repository +# with a throwaway key from the checkout's HEAD instead (tag is empty then), +# for pull requests and for as long as there is no tag or no signing key. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel) +out=${GITHUB_OUTPUT:-/dev/stdout} +pubkey=$here/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle + +tag=${INPUT_TAG:-} +if [ -z "$tag" ]; then + tag=$(git -C "$root" tag --list 'v[0-9]*' --sort=-v:refname | + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true) +fi +if [ -n "$tag" ] && [[ ! $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::invalid release tag: $tag" + exit 1 +fi +if [ -n "$tag" ] && ! git -C "$root" rev-parse --verify --quiet "refs/tags/$tag^{commit}" > /dev/null; then + echo "::error::no tag $tag in this repository" + exit 1 +fi +version=${tag#v} + +mapfile -t releases < <(grep -E '^[0-9]+$' "$here/fedora-releases") +release=$(sed -n 's/^Release: *\([0-9][0-9]*\).*/\1/p' "$here/rpm/nm-sshuttle-gnome.spec") +rel_version=$(sed -n 's/^Version: *//p' "$here/nm-sshuttle-release/nm-sshuttle-release.spec") +rel_release=$(sed -n 's/^Release: *//p' "$here/nm-sshuttle-release/nm-sshuttle-release.spec") + +# published reports whether a file is on the site. Only a 404 means it is +# not: on any other failure, guessing could rebuild and republish. +published() { + local status + status=$(curl -sSI --retry 3 -o /dev/null -w '%{http_code}' "$PAGES_URL/$1") || status=failed + case $status in + 200) return 0 ;; + 404) return 1 ;; + esac + echo "::error::checking $PAGES_URL/$1 failed ($status)" >&2 + exit 1 +} + +# superseded reports whether publish.sh would prune version $3 of package $2 +# at once for Fedora release $1, as it keeps only the KEEP_VERSIONS newest +# versions and the newest of the previous major.minor line (see +# prune-repo.py); the next run would then build it again. +listing= +superseded() { + local out status v kept=() line top_line version=$3 + if [ -z "$listing" ]; then + out=$(curl -sSL --retry 3 -w '\n%{http_code}' "$PAGES_URL/packages.sha256") || out=$'\nfailed' + status=${out##*$'\n'} + case $status in + 200) listing=${out%$'\n'*} ;; + 404) listing=none ;; + *) + echo "::error::fetching $PAGES_URL/packages.sha256 failed ($status)" >&2 + exit 1 ;; + esac + fi + local versions + versions=$( { + echo "$version" + sed -n "s|^.* rpm/fedora/$1/x86_64/$2-\\([0-9][^-/]*\\)-[^/]*\\.fc$1\\.x86_64\\.rpm\$|\\1|p" <<<"$listing" + } | sort -Vru) + top_line= + while read -r v; do + line=$(cut -d. -f1-2 <<<"$v") + [ -n "$top_line" ] || top_line=$line + if [ "${#kept[@]}" -lt "${KEEP_VERSIONS:-2}" ]; then + kept+=("$v") + elif [ "$line" != "$top_line" ]; then + kept+=("$v") + break + fi + if [ "$line" != "$top_line" ]; then + break + fi + done <<<"$versions" + for v in "${kept[@]}"; do + [ "$v" != "$version" ] || return 1 + done + return 0 +} + +build=false +release_missing=false +if [ -n "$tag" ]; then + for f in "${releases[@]}"; do + # nm-sshuttle-gnome is the arch-specific package, so it stands for both. + if ! published "rpm/fedora/$f/x86_64/nm-sshuttle-gnome-$version-$release.fc$f.x86_64.rpm"; then + if superseded "$f" nm-sshuttle-gnome "$version"; then + echo "::notice::the repository would not keep $tag next to the published versions for Fedora $f; not building it" >&2 + else + build=true + fi + fi + published "rpm/fedora/$f/x86_64/nm-sshuttle-release-$rel_version-$rel_release.noarch.rpm" || release_missing=true + done + if [ "${REBUILD:-false}" = true ]; then + build=true + fi +fi + +publish=none +if [ "${GITHUB_REF:-}" = refs/heads/main ] && [ -n "$tag" ] && [ -s "$pubkey" ]; then + if [ "$build" = true ] || [ "$release_missing" = true ] || [ "${GITHUB_EVENT_NAME:-}" = push ]; then + publish=real + fi +else + if [ ! -s "$pubkey" ]; then + echo "::warning::packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle is missing; run scripts/signing-key.sh. Publishing as a dry run with a throwaway key." + elif [ -z "$tag" ]; then + echo "::notice::no release tag yet; assembling the repository from HEAD as a dry run" + fi + # A dry run restores nothing and packages HEAD, which no tag names. + publish=dry-run + build=true + tag= +fi + +{ + echo "tag=$tag" + echo "fedora=$(printf '%s\n' "${releases[@]}" | jq -R . | jq -cs .)" + echo "build=$build" + echo "publish=$publish" +} >> "$out" diff --git a/packaging/prune-repo.py b/packaging/prune-repo.py new file mode 100755 index 0000000..525e0c3 --- /dev/null +++ b/packaging/prune-repo.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: MIT +"""Delete all but the newest N versions of each package in an RPM directory. + +Only the newest release of each of the N newest versions is kept, so a +release bump does not push the previous version out. The newest version of +the previous major.minor line is kept as well, so that a pin to the last +release before a minor change keeps resolving. + +Usage: prune-repo.py --keep N DIR +""" + +import argparse +import functools +import os +import re +import sys + +import rpm + + +def header(ts, path): + with open(path, "rb") as f: + return ts.hdrFromFdno(f.fileno()) + + +def evr(hdr): + epoch = hdr[rpm.RPMTAG_EPOCH] + return ( + "0" if epoch is None else str(epoch), + hdr[rpm.RPMTAG_VERSION], + hdr[rpm.RPMTAG_RELEASE], + ) + + +def line(version): + """The major.minor line of a version.""" + return tuple(version.split(".")[:2]) + + +def doomed(versions, keep): + """Return the paths to delete from versions, (evr, path) sorted newest first.""" + wanted = [] + for (epoch, version, _), _path in versions: + if (epoch, version) not in wanted and len(wanted) < keep: + wanted.append((epoch, version)) + newest_line = line(versions[0][0][1]) + previous = next(((e, v) for (e, v, _), _path in versions if line(v) != newest_line), None) + if previous is not None and previous not in wanted: + wanted.append(previous) + kept, paths = set(), [] + for (epoch, version, _), path in versions: + # Sorted newest first: the first build of a version is its newest. + if (epoch, version) in wanted and (epoch, version) not in kept: + kept.add((epoch, version)) + continue + paths.append(path) + return paths + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--keep", type=int, required=True) + parser.add_argument("dir") + args = parser.parse_args() + if args.keep < 1: + parser.error("--keep must be at least 1") + + ts = rpm.TransactionSet() + # Only names and versions are read; signatures are checked elsewhere. + ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES | rpm._RPMVSF_NODIGESTS) + + by_name = {} + for entry in sorted(os.listdir(args.dir)): + if not entry.endswith(".rpm"): + continue + path = os.path.join(args.dir, entry) + hdr = header(ts, path) + # The source directory holds one SRPM per Fedora release, and each + # release keeps its own versions. + dist = re.search(r"\.fc\d+$", hdr[rpm.RPMTAG_RELEASE]) + key = ( + hdr[rpm.RPMTAG_NAME], + hdr[rpm.RPMTAG_ARCH], + bool(hdr[rpm.RPMTAG_SOURCEPACKAGE]), + dist and dist.group(), + ) + by_name.setdefault(key, []).append((evr(hdr), path)) + + cmp = functools.cmp_to_key(lambda a, b: rpm.labelCompare(a[0], b[0])) + for versions in by_name.values(): + versions.sort(key=cmp, reverse=True) + for path in doomed(versions, args.keep): + print(f"pruning {path}", file=sys.stderr) + os.remove(path) + + +if __name__ == "__main__": + main() diff --git a/packaging/publish.sh b/packaging/publish.sh new file mode 100755 index 0000000..4a0aac0 --- /dev/null +++ b/packaging/publish.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Assemble the signed dnf repository site. +# +# Usage: publish.sh +# +# Restores the packages already published at $PAGES_URL (listed with their +# SHA-256 in packages.sha256), signs and files the new packages from +# , prunes old versions, verifies every package signature, +# regenerates and signs the repository metadata, and writes the static site +# into with a listing of every directory. +# +# Env: PAGES_URL published repository site +# KEEP_VERSIONS versions to keep per package, newest release each +# (default 2), plus the newest of the previous +# major.minor line +# RESTORE "false" starts from an empty repository instead of +# the published packages: for a dry run with another +# key, which could not verify them, and after a key +# compromise, when no listed key may (default true) +# RPM_SIGNING_KEY armored OpenPGP secret key without passphrase; it +# must be one of the keys that +# packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle lists +# +# Restored packages that another key listed there signed are re-signed with +# RPM_SIGNING_KEY, so that a rotation (see scripts/signing-key.sh) moves the +# whole repository to the new key. Packages that no listed key signed are an +# error. +set -euo pipefail + +incoming=${1:?usage: publish.sh } +site=${2:?usage: publish.sh } +here=$(cd "$(dirname "$0")" && pwd) +pubkey=$here/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle +: "${PAGES_URL:?}" "${RPM_SIGNING_KEY:?RPM_SIGNING_KEY is not set}" +mapfile -t releases < <(grep -E '^[0-9]+$' "$here/fedora-releases") + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export GNUPGHOME=$work/gnupg +mkdir -m 0700 "$GNUPGHOME" + +gpg --batch --quiet --import <<<"$RPM_SIGNING_KEY" +fpr=$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "fpr" { print $10; exit }') +listed=$(gpg --batch --with-colons --show-keys "$pubkey" | + awk -F: '$1 == "pub" { p = 1; next } $1 == "fpr" && p { print $10; p = 0 }') +if [ -z "$fpr" ] || ! grep -qx "$fpr" <<<"$listed"; then + echo "::error::signing key '$fpr' is not one of the keys in $pubkey: ${listed//$'\n'/ }" + exit 1 +fi +# rpmdb trusts the signing key only, trusted every key that $pubkey lists. +mkdir "$work/rpmdb" "$work/trusted" +gpg --batch --armor --export "$fpr" > "$work/signing-key.asc" +rpmkeys --dbpath "$work/rpmdb" --import "$work/signing-key.asc" +rpmkeys --dbpath "$work/trusted" --import "$pubkey" +signed() { + rpmkeys --dbpath "$work/$1" --checksig "$2" | grep -q 'signatures OK$' +} + +mkdir -p "$site" + +# Restore the published packages; the site is the repository's only state. +# Only a 404 means that nothing is published yet: any other failure would +# otherwise deploy a repository without the packages it had. +status= +if [ "${RESTORE:-true}" != false ]; then + status=$(curl -sSL --retry 3 -o "$work/packages.sha256" -w '%{http_code}' "$PAGES_URL/packages.sha256") || status=failed +fi +if [ "${RESTORE:-true}" = false ]; then + echo "not restoring the packages published at $PAGES_URL" +elif [ "$status" = 404 ]; then + echo "no packages.sha256 at $PAGES_URL; starting an empty repository" +elif [ "$status" != 200 ]; then + echo "::error::fetching $PAGES_URL/packages.sha256 failed ($status); not publishing a repository without its packages" + exit 1 +else + while read -r _ path; do + if [[ ! $path =~ ^rpm/fedora/([0-9]+/x86_64|source)/[A-Za-z0-9._+~^-]+\.rpm$ ]]; then + echo "::error::unexpected path in packages.sha256: $path" + exit 1 + fi + mkdir -p "$site/${path%/*}" + curl -fsSL --retry 3 "$PAGES_URL/$path" -o "$site/$path" + done < "$work/packages.sha256" + (cd "$site" && sha256sum --check --quiet --strict "$work/packages.sha256") + # The listings show when each package was first published. + if curl -fsSL "$PAGES_URL/packages.mtime" -o "$work/packages.mtime"; then + while read -r mtime path; do + if [[ ! $mtime =~ ^[0-9]+$ || ! $path =~ ^rpm/fedora/([0-9]+/x86_64|source)/[A-Za-z0-9._+~^-]+\.rpm$ ]]; then + echo "::error::unexpected line in packages.mtime: $mtime $path" + exit 1 + fi + touch -c -m -d "@$mtime" "$site/$path" + done < "$work/packages.mtime" + fi + # Move packages that another listed key signed onto the signing key, + # keeping their dates. + while read -r _ path; do + pkg=$site/$path + signed rpmdb "$pkg" && continue + if ! signed trusted "$pkg"; then + echo "::error::$path is not signed by a key that $pubkey lists; after a key compromise, publish with discard-published" + rpmkeys --dbpath "$work/trusted" --checksig --verbose "$pkg" + exit 1 + fi + mtime=$(stat -c %Y "$pkg") + rpmsign --define "_gpg_name $fpr" --resign "$pkg" > /dev/null + touch -m -d "@$mtime" "$pkg" + echo "re-signed $path" + done < "$work/packages.sha256" +fi + +# Sign and file the new packages. +shopt -s nullglob +for pkg in "$incoming"/*.rpm; do + rpmsign --define "_gpg_name $fpr" --addsign "$pkg" > /dev/null + name=${pkg##*/} + dests=() + case $name in + *.src.rpm) + dests=(rpm/fedora/source) ;; + nm-sshuttle-release-*.noarch.rpm) + for f in "${releases[@]}"; do dests+=("rpm/fedora/$f/x86_64"); done ;; + # Built per Fedora release, with a dist tag: nm-sshuttle (noarch) and + # nm-sshuttle-gnome, which ship side by side. + *.fc[0-9]*.x86_64.rpm | *.fc[0-9]*.noarch.rpm) + f=${name##*.fc} + dests=("rpm/fedora/${f%%.*}/x86_64") ;; + *) + echo "::error::cannot tell which repository $name belongs to" + exit 1 ;; + esac + for d in "${dests[@]}"; do + mkdir -p "$site/$d" + # A rebuild, such as for a newly added Fedora release, signs every + # package anew. Keep the one published under the same name, so that its + # bytes and its listed date stay put; a changed package needs a new + # version or baserelease. + cp -n "$pkg" "$site/$d/" + done +done + +# Every release repository carries the newest nm-sshuttle-release, also after a +# Fedora release was added without a new nm-sshuttle-release build. +newest=$(find "$site/rpm/fedora" -name 'nm-sshuttle-release-*.noarch.rpm' -printf '%f\t%p\n' | sort -V | tail -n1 | cut -f2) +if [ -z "$newest" ]; then + echo "::error::no nm-sshuttle-release package to publish" + exit 1 +fi +for f in "${releases[@]}"; do + mkdir -p "$site/rpm/fedora/$f/x86_64" + cp -np "$newest" "$site/rpm/fedora/$f/x86_64/" +done +cp -p "$newest" "$site/nm-sshuttle-release.noarch.rpm" + +# Prune, verify that the signing key signed every package, regenerate and +# sign the metadata. +for dir in "$site"/rpm/fedora/*/x86_64 "$site"/rpm/fedora/source; do + [ -d "$dir" ] || continue + python3 "$here/prune-repo.py" --keep "${KEEP_VERSIONS:-2}" "$dir" + for pkg in "$dir"/*.rpm; do + if ! signed rpmdb "$pkg"; then + echo "::error::bad or missing signature: $pkg" + rpmkeys --dbpath "$work/rpmdb" --checksig --verbose "$pkg" + exit 1 + fi + done + createrepo_c --quiet "$dir" + gpg --batch --yes --local-user "$fpr" --detach-sign --armor "$dir/repodata/repomd.xml" +done + +cp "$pubkey" "$site/RPM-GPG-KEY-nm-sshuttle" +grouped=$(sed -E 's/(.{4})/\1 /g; s/ $//' <<<"$fpr") +sed -e "s/@FINGERPRINT@/$grouped/g" "$here/site/index.html" > "$site/index.html" +(cd "$site" && find rpm -name '*.rpm' | LC_ALL=C sort | xargs -r sha256sum) > "$site/packages.sha256" +(cd "$site" && find rpm -name '*.rpm' | LC_ALL=C sort | xargs -r stat -c '%Y %n') > "$site/packages.mtime" +python3 "$here/site-index.py" "$PAGES_URL" "$site" +echo "assembled $(wc -l < "$site/packages.sha256") packages signed by $fpr" diff --git a/packaging/site-index.py b/packaging/site-index.py new file mode 100755 index 0000000..a67fadf --- /dev/null +++ b/packaging/site-index.py @@ -0,0 +1,110 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: MIT +"""Write directory listings, like Apache's, into the repository site. + +Usage: site-index.py PAGES_URL SITE + +Every directory below SITE gets an index.html that lists its entries with +links, times and sizes. The landing page, SITE/index.html, keeps its content +and gets the listing of SITE in place of its marker; the +other pages reuse its stylesheet. +""" + +import datetime +import html +import os +import re +import sys +import urllib.parse + +MARKER = "" + +PAGE = """ + + + + +Index of {path} +{style} + + +

Index of {path}

+{listing} + + +""" + + +def newest_mtime(path): + """The time of the newest file below path, listings aside.""" + times = [os.stat(os.path.join(root, name)).st_mtime + for root, _, files in os.walk(path) for name in files if name != "index.html"] + return max(times, default=os.stat(path).st_mtime) + + +def human(size): + for unit in ("", "K", "M", "G"): + if size < 1024 or unit == "G": + break + size /= 1024 + if unit == "": + return str(int(size)) + return f"{size:.1f}{unit}" if size < 10 else f"{size:.0f}{unit}" + + +def row(href, name, mtime, size): + when = "" if mtime is None else datetime.datetime.fromtimestamp( + mtime, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M") + return (f'{html.escape(name)}' + f"{when}{size}") + + +def listing(directory, parent): + rows = [] + if parent: + rows.append(row("../", "Parent Directory", None, "-")) + entries = sorted(os.scandir(directory), key=lambda e: (not e.is_dir(), e.name)) + for entry in entries: + if entry.name == "index.html": + continue + quoted = urllib.parse.quote(entry.name) + if entry.is_dir(): + rows.append(row(quoted + "/", entry.name + "/", newest_mtime(entry.path), "-")) + else: + st = entry.stat() + rows.append(row(quoted, entry.name, st.st_mtime, human(st.st_size))) + return ('\n' + "" + '\n\n' + + "\n".join(rows) + "\n\n
NameLast modifiedSize
") + + +def main(): + if len(sys.argv) != 3: + sys.exit(__doc__.strip().splitlines()[2]) + base = urllib.parse.urlsplit(sys.argv[1]).path.rstrip("/") + "/" + site = sys.argv[2] + + landing = os.path.join(site, "index.html") + with open(landing, encoding="utf-8") as f: + page = f.read() + if page.count(MARKER) != 1: + sys.exit(f"{landing} needs exactly one {MARKER} marker") + style = re.search(r"", page, re.S) + + for root, dirs, _ in os.walk(site): + dirs.sort() + rel = os.path.relpath(root, site) + if rel == ".": + continue + path = base + rel.replace(os.sep, "/") + "/" + with open(os.path.join(root, "index.html"), "w", encoding="utf-8") as f: + f.write(PAGE.format(path=html.escape(path), style=style.group(0) if style else "", + listing=listing(root, parent=True))) + + with open(landing, "w", encoding="utf-8") as f: + f.write(page.replace(MARKER, listing(site, parent=False))) + + +if __name__ == "__main__": + main() diff --git a/packaging/site/index.html b/packaging/site/index.html new file mode 100644 index 0000000..4ab5167 --- /dev/null +++ b/packaging/site/index.html @@ -0,0 +1,40 @@ + + + + + +nm-sshuttle package repository + + + +

nm-sshuttle package repository

+

Fedora packages of nm-sshuttle, +the NetworkManager VPN plugin that tunnels with sshuttle, built from source and +signed by the nm-sshuttle project.

+ +

Install

+
sudo rpm --import https://dennisklein.github.io/nm-sshuttle/RPM-GPG-KEY-nm-sshuttle
+sudo dnf install https://dennisklein.github.io/nm-sshuttle/nm-sshuttle-release.noarch.rpm
+sudo dnf install nm-sshuttle
+

To create and edit profiles in GNOME Settings as well, install +nm-sshuttle-gnome. +Source RPMs are in the nm-sshuttle-source repository (disabled by default).

+ +

Signing key

+

Packages and repository metadata are signed with +RPM-GPG-KEY-nm-sshuttle, fingerprint:

+
@FINGERPRINT@
+ +

Files

+ + + diff --git a/packaging/smoke-test.sh b/packaging/smoke-test.sh new file mode 100755 index 0000000..86a2930 --- /dev/null +++ b/packaging/smoke-test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Install the nm-sshuttle packages from a repository site the way users do and +# check that they are signed and in place. Meant for a throwaway Fedora +# container. +# +# Usage: smoke-test.sh +set -euo pipefail + +base=${1:?usage: smoke-test.sh } +base=${base%/} + +curl -fsSL --retry 5 --retry-all-errors "$base/RPM-GPG-KEY-nm-sshuttle" -o /tmp/RPM-GPG-KEY-nm-sshuttle +rpm --import /tmp/RPM-GPG-KEY-nm-sshuttle +dnf -y install "$base/nm-sshuttle-release.noarch.rpm" +# nm-sshuttle.repo points at the public site; follow the site under test +# instead. --allowerasing: the container has systemd-standalone-tmpfiles, +# which conflicts with the systemd that systemd-resolved needs; a real system +# has the full systemd. +dnf -y install --allowerasing --setopt="nm-sshuttle.baseurl=$base/rpm/fedora/\$releasever/\$basearch/" \ + nm-sshuttle nm-sshuttle-gnome + +rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}: %{SIGPGP:pgpsig}\n' \ + nm-sshuttle-release nm-sshuttle nm-sshuttle-gnome + +# The files of both packages are in place, and the profile commands run. +nm-sshuttle --help > /dev/null +test -s /usr/lib/NetworkManager/VPN/nm-sshuttle-service.name +test -s /usr/lib64/NetworkManager/libnm-gtk4-vpn-plugin-sshuttle-editor.so diff --git a/scripts/signing-key.sh b/scripts/signing-key.sh new file mode 100755 index 0000000..b78a078 --- /dev/null +++ b/scripts/signing-key.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# +# Create and rotate the OpenPGP keys that sign nm-sshuttle's RPM packages and +# repository metadata. Run it on a trusted machine, from a checkout of the +# repo. +# +# Usage: signing-key.sh create the first key +# signing-key.sh --next add a new key beside the current one +# signing-key.sh --switch FILE sign with the secret key in FILE +# signing-key.sh --retire FPR stop trusting key FPR +# +# Without options, it +# - generates an RSA-4096 signing key without passphrase or expiry in a +# throwaway GnuPG home +# - creates the GitHub environment rpm-signing if needed and restricts it to +# the main branch, so that only workflow runs on main can read its secrets; +# it removes any other deployment branch or tag policy of that environment +# - stores the secret key as secret RPM_SIGNING_KEY of that environment via +# the gh CLI +# - writes the public key to packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle, to be +# committed +# - keeps a backup of the secret key and its revocation certificate in +# ${XDG_DATA_HOME:-~/.local/share}/nm-sshuttle/signing-key/; move them to offline +# storage +# +# RPM-GPG-KEY-nm-sshuttle lists every key that clients trust, and publish.sh signs +# with one of them, re-signing the packages it restores that another listed +# key signed. A planned rotation therefore takes three steps, each committed +# with a new nm-sshuttle-release Version, so that clients get the new key from a +# package signed by one they trust: +# 1. --next generates a key, adds it to RPM-GPG-KEY-nm-sshuttle and backs it up as +# above, without signing with it yet. +# 2. Once clients have updated nm-sshuttle-release, --switch with that backup makes +# the next publish sign everything with the new key. +# 3. After that publish, --retire removes the old key. +# +# Env: NM_SSHUTTLE_SIGNING_UID key user ID (default: nm-sshuttle package signing ) +# NM_SSHUTTLE_REPO GitHub repository (default: dennisklein/nm-sshuttle) +set -euo pipefail + +uid=${NM_SSHUTTLE_SIGNING_UID:-nm-sshuttle package signing } +repo=${NM_SSHUTTLE_REPO:-dennisklein/nm-sshuttle} +environment=rpm-signing +secret=RPM_SIGNING_KEY + +mode=${1:-create} +case $mode in + create) [ $# -eq 0 ] ;; + --next) [ $# -eq 1 ] ;; + --switch | --retire) [ $# -eq 2 ] ;; + *) false ;; +esac || { echo "usage: $0 [--next | --switch SECRET-KEY-FILE | --retire FINGERPRINT]" >&2; exit 2; } + +for cmd in gpg gh git; do + command -v "$cmd" > /dev/null || { echo "$cmd is required" >&2; exit 1; } +done +root=$(git rev-parse --show-toplevel) +pubkey=$root/packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle +backup=${XDG_DATA_HOME:-$HOME/.local/share}/nm-sshuttle/signing-key + +GNUPGHOME=$(mktemp -d) +export GNUPGHOME +trap 'rm -rf "$GNUPGHOME"' EXIT + +# fingerprints prints the fingerprints of the primary keys in file $1. +fingerprints() { + gpg --batch --with-colons --show-keys "$1" | + awk -F: '$1 == "pub" || $1 == "sec" { p = 1; next } $1 == "fpr" && p { print $10; p = 0 }' +} + +# generate creates a key in $GNUPGHOME, backs it up and sets $fpr. --yes +# lets --next give the new key the user ID of the current one. +generate() { + gpg --batch --quiet --yes --pinentry-mode loopback --passphrase '' \ + --quick-gen-key "$uid" rsa4096 sign never + fpr=$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "fpr" { print $10; exit }') + ( + umask 077 + mkdir -p "$backup" + gpg --batch --pinentry-mode loopback --passphrase '' --armor \ + --export-secret-keys "$fpr" > "$backup/$fpr.secret.asc" + cp "$GNUPGHOME/openpgp-revocs.d/$fpr.rev" "$backup/$fpr.rev" + ) +} + +if [ "$mode" != create ] && [ ! -e "$pubkey" ]; then + echo "$pubkey does not exist; create the first key without options" >&2 + exit 1 +fi +case $mode in + --next) + # Export the listed keys and the new one as one keyring, which rpm and + # dnf import as a whole. + gpg --batch --quiet --import "$pubkey" + generate + gpg --batch --armor --export > "$pubkey.new" + mv "$pubkey.new" "$pubkey" + cat <&2 + exit 1 + fi + gh auth status > /dev/null + gh secret set "$secret" --repo "$repo" --env "$environment" < "$2" + cat <&2 + exit 1 + fi + if [ "${#listed[@]}" -lt 2 ]; then + echo "$2 is the only key in ${pubkey#"$root"/}; add its successor with --next first" >&2 + exit 1 + fi + id=${2: -8} + gpg --batch --quiet --import "$pubkey" + gpg --batch --yes --delete-keys "$2" + gpg --batch --armor --export > "$pubkey.new" + mv "$pubkey.new" "$pubkey" + cat <&2 + exit 1 +fi +gh auth status > /dev/null +# The public key may be missing from this checkout although the secret exists, +# e.g. in a clone from before the key was committed. Only a 404 means that +# neither the environment nor the secret exists yet. +if out=$(gh api "repos/$repo/environments/$environment/secrets/$secret" 2>&1); then + echo "$secret already exists in the $environment environment of $repo; update this checkout to get ${pubkey#"$root"/}" >&2 + echo "or, to replace the key, use --next and --switch" >&2 + exit 1 +elif ! grep -q 'HTTP 404' <<< "$out"; then + echo "cannot check for an existing $secret: $out" >&2 + exit 1 +fi + +# Restrict the environment before any key exists. This replaces the +# environment's protection rules with a deployment branch policy and removes +# every other branch or tag policy an existing environment may have, so that +# main is the only ref that can read the key. +gh api --silent --method PUT "repos/$repo/environments/$environment" \ + -F 'deployment_branch_policy[protected_branches]=false' \ + -F 'deployment_branch_policy[custom_branch_policies]=true' +policies="repos/$repo/environments/$environment/deployment-branch-policies" +# Read the listing into a variable first: a failed listing then aborts the +# script, where one read through process substitution would pass as "no +# policies". +list=$(gh api --paginate "$policies" --jq '.branch_policies[] | [.id, .type, .name] | @tsv') +have_main= +while IFS=$'\t' read -r id type name; do + [ -n "$id" ] || continue + if [ "$type" = branch ] && [ "$name" = main ]; then + have_main=1 + else + echo "removing $type policy $name from the $environment environment" >&2 + gh api --silent --method DELETE "$policies/$id" + fi +done <<< "$list" +if [ -z "$have_main" ]; then + gh api --silent --method POST "$policies" -f name=main -f type=branch +fi + +generate +gh secret set "$secret" --repo "$repo" --env "$environment" < "$backup/$fpr.secret.asc" +gpg --batch --armor --export "$fpr" > "$pubkey" + +cat < Date: Sun, 11 Oct 2026 19:53:44 +0000 Subject: [PATCH 2/4] ci: publish the Fedora repository to GitHub Pages - packages builds SRPMs, then RPMs per Fedora release, signs and assembles the repository, smoke-tests it and deploys it - pull requests and runs without a tag or signing key assemble the repository with a throwaway key and deploy nothing - release starts packages on main for each pushed release tag, because only main may use the signing key Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/packages.yaml | 323 ++++++++++++++++++++++++++++++++ .github/workflows/release.yaml | 26 +++ 2 files changed, 349 insertions(+) create mode 100644 .github/workflows/packages.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/packages.yaml b/.github/workflows/packages.yaml new file mode 100644 index 0000000..9e28109 --- /dev/null +++ b/.github/workflows/packages.yaml @@ -0,0 +1,323 @@ +name: packages + +# Builds signed Fedora RPMs of nm-sshuttle from a release tag and publishes +# them, with the nm-sshuttle-release repository package, as a dnf repository +# on GitHub Pages. See packaging/ for the scripts each job runs. The release +# workflow starts this one after a tag is pushed. Pull requests, and +# runs before the first tag or the signing key exist, assemble and test the +# repository with a throwaway key instead of publishing it. + +on: + push: + branches: + - main + paths: + - packaging/** + - .github/workflows/packages.yaml + pull_request: + paths: + - packaging/** + - .github/workflows/packages.yaml + workflow_dispatch: + inputs: + tag: + description: Release tag to package (default newest vX.Y.Z tag) + required: false + default: "" + rebuild: + description: Rebuild even if the packages are already published + type: boolean + default: false + discard-published: + description: >- + Publish only what this run builds, dropping the published packages + (after a signing key compromise; implies rebuild) + type: boolean + default: false + +permissions: {} + +# By default a waiting run is replaced by a newer one, which would drop a +# push's republish or a dispatch's rebuild. Queue them all instead. +concurrency: + group: packages-${{ github.ref }} + cancel-in-progress: false + queue: max + +env: + PAGES_URL: https://dennisklein.github.io/nm-sshuttle + KEEP_VERSIONS: "2" + +# Job names are fixed: GitHub shows the name of a skipped job unexpanded, and +# appends the Fedora release to the names of the matrix jobs itself. The plan +# job's summary names what a run builds. +jobs: + plan: + name: Plan what to build and publish + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.plan.outputs.tag }} + fedora: ${{ steps.plan.outputs.fedora }} + build: ${{ steps.plan.outputs.build }} + publish: ${{ steps.plan.outputs.publish }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - id: plan + env: + INPUT_TAG: ${{ inputs.tag }} + REBUILD: ${{ inputs.rebuild || inputs.discard-published }} + run: packaging/plan.sh + - name: Summary + env: + TAG: ${{ steps.plan.outputs.tag }} + FEDORA: ${{ steps.plan.outputs.fedora }} + BUILD: ${{ steps.plan.outputs.build }} + PUBLISH: ${{ steps.plan.outputs.publish }} + run: | + echo "nm-sshuttle ${TAG:-HEAD} for Fedora $FEDORA: build=$BUILD publish=$PUBLISH" >> "$GITHUB_STEP_SUMMARY" + + srpm: + name: Source RPMs + needs: plan + if: needs.plan.outputs.build == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + container: + image: registry.fedoraproject.org/fedora:44 + steps: + - name: Install tools + run: dnf -y install git-core rpm-build + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Build source RPMs + env: + TAG: ${{ needs.plan.outputs.tag }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + packaging/make-srpm.sh out ${TAG:+"$TAG"} + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: srpm-nm-sshuttle + path: out/*.src.rpm + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + rpm: + name: RPMs for Fedora + needs: [plan, srpm] + runs-on: ubuntu-latest + permissions: {} + strategy: + fail-fast: false + matrix: + fedora: ${{ fromJSON(needs.plan.outputs.fedora) }} + container: + image: registry.fedoraproject.org/fedora:${{ matrix.fedora }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: srpm-nm-sshuttle + - name: Install build dependencies + run: | + dnf -y install rpm-build dnf5-plugins + for srpm in ./*.src.rpm; do dnf -y builddep "$srpm"; done + - name: Build RPMs + run: | + # The binaries name the SRPM of this build as their SOURCERPM, with + # this release's dist tag. Publish that SRPM, so that dnf can find + # the source of an installed package in the nm-sshuttle-source + # repository. + for srpm in ./*.src.rpm; do + rpmbuild -rs --nodeps --define "_topdir $PWD/rpmbuild" "$srpm" + done + # One rpmbuild per SRPM: given several, the noarch package would + # make the next one noarch too. + for srpm in rpmbuild/SRPMS/*.src.rpm; do + rpmbuild --rebuild --define "_topdir $PWD/rpmbuild" "$srpm" + done + mkdir out + cp -t out rpmbuild/SRPMS/*.src.rpm + find rpmbuild/RPMS -name '*.rpm' ! -name '*-debuginfo-*' ! -name '*-debugsource-*' -exec cp -t out {} + + ls -l out + for pkg in out/*.rpm; do rpm -qp --requires "$pkg" | sed "s|^|${pkg##*/}: |"; done + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: rpm-nm-sshuttle-fc${{ matrix.fedora }} + path: out/*.rpm + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + release-rpm: + name: nm-sshuttle-release RPM + needs: plan + if: needs.plan.outputs.publish == 'real' + runs-on: ubuntu-latest + permissions: + contents: read + container: + image: registry.fedoraproject.org/fedora:44 + steps: + - run: dnf -y install rpm-build + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build nm-sshuttle-release + run: | + packaging/nm-sshuttle-release/build.sh out + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: rpm-nm-sshuttle-release + path: out/*.rpm + if-no-files-found: error + retention-days: 7 + + publish: + name: Sign and assemble the repository + needs: [plan, srpm, rpm, release-rpm] + # A build job that timed out ends cancelled, which !failure() misses. + # Without a build, the published packages are restored. + if: >- + !cancelled() && needs.plan.outputs.publish == 'real' && + contains(fromJSON('["success","skipped"]'), needs.srpm.result) && + contains(fromJSON('["success","skipped"]'), needs.rpm.result) && + needs.release-rpm.result == 'success' + runs-on: ubuntu-latest + environment: rpm-signing + permissions: + contents: read + container: + image: registry.fedoraproject.org/fedora:44 + steps: + - run: dnf -y install createrepo_c findutils gnupg2 jq python3-rpm rpm-sign + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: "rpm-*" + path: incoming + merge-multiple: true + - name: Assemble signed repository + env: + RPM_SIGNING_KEY: ${{ secrets.RPM_SIGNING_KEY }} + RESTORE: ${{ inputs.discard-published && 'false' || 'true' }} + run: packaging/publish.sh incoming site + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + # For the smoke test, which installs from it before it goes live. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: site + path: site + if-no-files-found: error + retention-days: 7 + + deploy: + name: Deploy to GitHub Pages + needs: [publish, smoke-test] + # A publish without a build skips the build jobs, and the default + # condition would skip this job with them. + if: >- + !cancelled() && needs.publish.result == 'success' && + needs.smoke-test.result == 'success' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + + # Assembles the repository with a throwaway key, so packaging changes are + # verified end to end (see smoke-test) without the real key or a deploy. + dry-run: + name: Assemble the repository with a throwaway key + needs: [plan, srpm, rpm] + if: >- + !cancelled() && needs.plan.outputs.publish == 'dry-run' && + needs.srpm.result == 'success' && needs.rpm.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + container: + image: registry.fedoraproject.org/fedora:44 + steps: + - run: dnf -y install createrepo_c findutils gnupg2 jq python3-rpm rpm-build rpm-sign + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: "rpm-*" + path: incoming + merge-multiple: true + - name: Assemble repository with a throwaway key + env: + # The published packages carry the real key's signatures. + RESTORE: "false" + run: | + GNUPGHOME=$(mktemp -d) + export GNUPGHOME + gpg --batch --quiet --pinentry-mode loopback --passphrase '' \ + --quick-gen-key 'nm-sshuttle dry run ' rsa4096 sign never + gpg --batch --armor --export > packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle + RPM_SIGNING_KEY=$(gpg --batch --armor --export-secret-keys) + export RPM_SIGNING_KEY + rm -rf "$GNUPGHOME" + unset GNUPGHOME + packaging/nm-sshuttle-release/build.sh incoming + packaging/publish.sh incoming site + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: site + path: site + if-no-files-found: error + retention-days: 7 + + # Installs from the assembled site, real or dry run, before a real one is + # deployed. + smoke-test: + name: Smoke test on Fedora + needs: [plan, publish, dry-run] + if: >- + !cancelled() && + (needs.publish.result == 'success' || needs.dry-run.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + fedora: ${{ fromJSON(needs.plan.outputs.fedora) }} + container: + image: registry.fedoraproject.org/fedora:${{ matrix.fedora }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: site + path: site + - name: Install from the repository + run: | + dnf -y install python3 + python3 -m http.server --directory site 8080 > /dev/null 2>&1 & + sleep 2 + packaging/smoke-test.sh http://127.0.0.1:8080 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..dbb11cd --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,26 @@ +name: release + +# Puts a tagged release into the package repository. The repository is +# published from main, the only branch that may use the signing key, so this +# workflow starts the packages workflow there instead of publishing itself. +# Pre-releases (a tag with a hyphen, such as v1.0.0-rc.1) are not packaged. + +on: + push: + tags: + - "v[0-9]*" + +permissions: {} + +jobs: + packages: + if: ${{ !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Run the packages workflow + run: gh workflow run packages.yaml --ref main -f tag="$GITHUB_REF_NAME" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} From 495739a2d37d1c9fa8fc499cf54f63d5cb2a3db9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:53:44 +0000 Subject: [PATCH 3/4] docs: install Fedora packages from the dnf repository - user guide: repository install steps ahead of the CI artifacts - README: point to the repository and to packaging/ Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +++- docs/user-guide.md | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8b93b02..885ad8b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ $ nmcli connection up corp # or the VPN toggle in Quick Settings own package, `nm-sshuttle-gnome`. Its validation reads the same [test cases](tests/profile-cases.json) as the plugin's. - [`packaging/rpm/`](packaging/rpm) and [`debian/`](debian) build the - Fedora and Debian/Ubuntu packages. + Fedora and Debian/Ubuntu packages. [`packaging/`](packaging) also builds + and signs the Fedora dnf repository on GitHub Pages + () from release tags. - [`docs/design.md`](docs/design.md) is the design: the research findings, the options considered, the architecture, the risks and the roadmap. - [`test-vm/`](test-vm) runs the plugin's scenarios in a throwaway Fedora diff --git a/docs/user-guide.md b/docs/user-guide.md index e33ccaa..a892890 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -24,7 +24,16 @@ it. ## Installing -The packages are built in CI (`rpm` and `deb` jobs; download them from a +On Fedora, install from the signed package repository, which also brings +updates: + +```console +$ sudo rpm --import https://dennisklein.github.io/nm-sshuttle/RPM-GPG-KEY-nm-sshuttle +$ sudo dnf install https://dennisklein.github.io/nm-sshuttle/nm-sshuttle-release.noarch.rpm +$ sudo dnf install nm-sshuttle +``` + +The packages are also built in CI (`rpm` and `deb` jobs; download them from a run's artifacts) or from the repository: ```console @@ -35,9 +44,9 @@ $ sudo apt install ./nm-sshuttle_*_all.deb ``` To create and edit profiles in GNOME Settings as well, install the editor, -which is a separate package (`nm-sshuttle-gnome-*.rpm`, -`nm-sshuttle-gnome_*.deb`). Settings reads its list of VPN types once, so -restart it afterwards (log out, or `pkill gnome-control-center`). +which is a separate package (`nm-sshuttle-gnome`; `nm-sshuttle-gnome-*.rpm`, +`nm-sshuttle-gnome_*.deb` as files). Settings reads its list of VPN types +once, so restart it afterwards (log out, or `pkill gnome-control-center`). From source: From d10fa4faafe3b06ee4534f162ff051cfb6837e3f Mon Sep 17 00:00:00 2001 From: Dennis Klein Date: Sun, 11 Oct 2026 22:13:57 +0200 Subject: [PATCH 4/4] chore: add repo signing key --- .../RPM-GPG-KEY-nm-sshuttle | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle diff --git a/packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle b/packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle new file mode 100644 index 0000000..ed37e6c --- /dev/null +++ b/packaging/nm-sshuttle-release/RPM-GPG-KEY-nm-sshuttle @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGrL7R4BEACyi4msGCKMqGTItIdewMhdiQcsu/KjJ5lTpm5k9Res9tP+JHRC +kNAbSHzDHenjevJp7Af0mdMFsUetxdl1S6aW6iCv//fRiC2Bw9JqFlLIr0K8DYe9 +n0xyn4JkioDojHf4jaIuQNpt4NXB8xmU9Pm8eWo22GYeag+gXvspgCy2SbuPmrue +yMO0elAqrZTYec5FKGNRRCWB1SMWZF28/tmgRBYkTN57Tyw1TS10ubz0DiEjCT7f +ACSns2am6rTzaWdGthFnjHtNzwj/AoHdGuZaavEZNpxQCxCWsnZcsypl4CHb+Yxl +XvQ6i3NM/2X6dUEAGtPEwlODU8ZZO4vkYfjv1pfoxRdmf3ddRoNz3zhX7dGJbzBQ +1Pj7rG1WOXA4nbw6HMhTmgvm13f0axhs2A2cbiTxzV58KPW1qoMujYBVe7vYJtea +IA01WzEWpvQxtJuOFbqbX4AY2DZ9Me2Dp1kRGr3+uFMoCFVfYTG0EKc5E2L0HXqF +FlSFjxm8bA2atjL6NuI/PNfpSkwkn/VoMvzX8pMT6UGP4vbI3mde8dJMahqY8e/W +PIdCWB+S/krRYE7vXrUX4iI12KGleZWxBVFvjFlawKpZtpSfFoJRnP8FKxrSKvQ/ +LlXp71awqyFYY3CtJdeEGPw9dj0EkALFOGXSlcPKrAdNwXGzY+kwwLjj9QARAQAB +tDVubS1zc2h1dHRsZSBwYWNrYWdlIHNpZ25pbmcgPGlsbXQyMDAwQGdvb2dsZW1h +aWwuY29tPokCUQQTAQoAOxYhBGLUUCMjlu20m/+OcRCaLBvTTUocBQJqy+0eAhsD +BQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEBCaLBvTTUocZ5cP/1tUvApX +r+FcrZakwZTV/faLDhzV8aktzsW9bZh6/n27Fl2vIoB1bogE2ISeEe81fFgs70tU +NNyW57jrAa/d0qk+sMZF5KyOUlYsHsil+78a2+wljIn6QcUpTmiU8erFENHSUDw5 +x0CbERhj6DluYoSnjZt1hFGxfiDDCNkTb8c6alIbCCdlLqqUZBcy9W5Opf/IA2Y9 +OfZJBC4LjOzv5oncdxF9eSQeq7/OOKWrHQ1EWbqI0itBPFBm2bSQ7pfquvq31Rzp +crGh7PqeZM5XK086pgZJSAG58eV6TO4ZV1fGb9h+WVw1hOoCfGW3a8nX6Be2Yzi7 +gzA0K3EbEO9zS1REFWBTUyfeOGRWtNLqBJOz1lDkggLMTvOOvUtNUiQ32XwtbXtc +zlcBmbZOkId+pDOcpPsaanQBViPJmHpRbsXrCHlt0r5gJlhbQf4hv6K3Ky0Q626Z +hr3waTBVFOsOD/OcDYZSwDnil0uh62hRr+PzXXKUJSOnkZH7MDGa5hWOHbJm3Saf +uGpCqUlulZtYAsIyUE5fLzdt1xy4bjuXUfCAyu55qzjH3XcCD/y2f+SV7O5cxFAv +ff7mQrE4vNZS1p0kKEwG4cdh2V9djKzwagDjo0nkcjIV1OGsyonX82IjIQ/H4mKU +OThswEeJh+OmgzqqPMG+PT+ajKS2bbP6CJCo +=v8qr +-----END PGP PUBLIC KEY BLOCK-----