From 1be48688755dc41e7f8c52e608bff9a61c684ffe Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 19 Aug 2026 13:46:11 +0000 Subject: [PATCH] build: Sign RPM packages (#8046) --- .github/scripts/strategy-matrix/linux.json | 4 +- .github/workflows/on-tag.yml | 1 + .github/workflows/on-trigger.yml | 1 + .github/workflows/reusable-package.yml | 11 ++++ package/README.md | 20 +++++-- package/sign_rpm.sh | 65 ++++++++++++++++++++++ 6 files changed, 94 insertions(+), 8 deletions(-) create mode 100755 package/sign_rpm.sh diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index bd3446f599..e739a42d5a 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -92,7 +92,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-028ccea" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" } ], @@ -102,7 +102,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-028ccea" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" } ] } diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index 1c9fb414f2..d8a9a5113e 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -49,3 +49,4 @@ jobs: secrets: remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 0d679318a9..1da0f47bc4 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -120,3 +120,4 @@ jobs: secrets: remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 430072b627..cfae706ee1 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -29,6 +29,9 @@ on: remote_password: description: "The password or token for that Nexus account." required: false + signing_key: + description: "Armoured PGP private key used to sign the RPMs. Required when publishing." + required: false defaults: run: @@ -98,6 +101,14 @@ jobs: PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} run: ./package/build_pkg.sh + # Before the upload, so the artifact and the published package are the + # same bytes. DEBs are not signed, so the key is never set on that job. + - name: Sign RPM + if: ${{ inputs.publish && matrix.distro == 'rhel' }} + env: + PKG_SIGNING_KEY: ${{ secrets.signing_key }} + run: ./package/sign_rpm.sh "${BUILD_DIR}" + - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/package/README.md b/package/README.md index 4899ee203e..516dcf9d9b 100644 --- a/package/README.md +++ b/package/README.md @@ -9,6 +9,7 @@ a build configured with `-Dvalidator_keys=ON`. ``` package/ build_pkg.sh Staging and build script (called by the CMake `package` target and CI) + sign_rpm.sh Signs the built RPMs (called by CI when publishing) publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) rpm/ xrpld.spec RPM spec @@ -32,7 +33,7 @@ package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | -| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild` | +| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | To print the full packaging matrix (artifact names and images) for the current @@ -152,11 +153,14 @@ any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the `NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the Conan remote. -Nexus owns the repository metadata; nothing here signs or indexes anything. Worth -knowing: +Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: - Each apt-hosted repository needs a distribution and a PGP signing keypair - configured in Nexus, which rejects one created without a keypair. + configured in Nexus, which rejects one created without a keypair. Nexus signs + the apt metadata with it, never the packages. +- Hosted yum repositories cannot be signed by Nexus at all, so `sign_rpm.sh` + signs the RPMs before they are uploaded, and rpm clients verify with + `gpgcheck=1` rather than `repo_gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. - Each job uploads only what it built, and uploads are not transactional, so a @@ -212,8 +216,12 @@ fail early. Flags are for explicit invocation; environment variables are intended for CMake/CI integration. The CI workflow and the CMake `package` target both invoke `build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR` and `PKG_RELEASE` via env -and lets the script use defaults for the rest. +`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and +`PKG_CHANNEL` via env and lets the script use defaults for the rest. + +Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +that only runs when publishing, so a published RPM is always signed and a local +build never needs a key. It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh new file mode 100755 index 0000000000..7a1d6f00e3 --- /dev/null +++ b/package/sign_rpm.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sign the RPMs built by build_pkg.sh. Nexus cannot sign hosted yum metadata, so +# the packages carry the signature themselves and rpm clients verify them with +# gpgcheck=1. +# +# Usage: sign_rpm.sh [package-dir] +# +# package-dir searched recursively for *.rpm ('build' by default) +# +# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep +# the key out of the process list. +# +# There is no DEB equivalent: apt trusts the repository metadata, which Nexus +# signs, rather than the packages themselves. + +pkg_dir="${1:-build}" + +mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0) + +# Signing nothing would otherwise look like a successful signing. +if [[ ${#rpms[@]} -eq 0 ]]; then + echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2 + exit 1 +fi + +: "${PKG_SIGNING_KEY:?is required}" + +# Global, and expanded by the trap when it fires: the keyring holds an +# unencrypted private key, so it must go even if signing fails. +signing_home="$(mktemp -d)" +trap 'rm -rf "${signing_home}"' EXIT +export GNUPGHOME="${signing_home}" + +printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import + +# Exactly one secret key, so that picking the first below is not a guess between +# several. +secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)" +if [[ "${secrets}" -ne 1 ]]; then + echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2 + exit 1 +fi + +key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')" +echo "Signing ${#rpms[@]} RPM(s) with ${key}." + +# Loopback pinentry: the key is unattended, so there is no tty to prompt on. +rpmsign \ + --define "_gpg_name ${key}" \ + --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \ + --addsign "${rpms[@]}" + +# rpmsign can exit 0 having attached nothing, and an unsigned package is only +# rejected later, on the installing machine. Both header tags are checked +# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in +# DSAHEADER. +for pkg in "${rpms[@]}"; do + signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")" + if [[ "${signature}" == "(none)(none)" ]]; then + echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2 + exit 1 + fi +done