diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7fef6643ff..7a3b7a8cf5 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -57,7 +57,9 @@ class LinuxConfig: sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" - image: str = "" # only used by package_configs entries + # The two below are only used by package_configs entries. + image: str = "" + package_type: str = "" # "deb" or "rpm"; has to match what image provides @dataclasses.dataclass @@ -156,7 +158,7 @@ class PackagingEntry: xrpld_artifact_name: str validator_keys_artifact_name: str image: str - distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps + package_type: str # "deb" or "rpm"; drives the format-specific steps # --------------------------------------------------------------------------- @@ -243,7 +245,7 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: xrpld_artifact_name=f"xrpld-{config_name}", validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, - distro=distro, + package_type=cfg.package_type, ) ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index e739a42d5a..8450c3079e 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -92,7 +92,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88", + "package_type": "deb" } ], @@ -102,7 +103,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88", + "package_type": "rpm" } ] } diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index cfae706ee1..4d1968b93c 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,9 +1,9 @@ # Build Linux packages from the pre-built xrpld and validator-keys artifacts: # # - one job per distro, taken from "package_configs" in linux.json -# - each job runs in that distro's container, which is what decides DEB or RPM +# - each entry names its container image and the format it builds there # - with 'publish: true' a job also uploads what it built -# (see package/publish_pkg.sh) +# (see package/publish_pkg.py) # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -97,17 +97,23 @@ jobs: - name: Build package env: + PACKAGE_TYPE: ${{ matrix.package_type }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} - PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} - run: ./package/build_pkg.sh + CHANNEL: ${{ steps.release_info.outputs.channel }} + run: | + ./package/build_pkg.py \ + --package-type "${PACKAGE_TYPE}" \ + --build-dir "${BUILD_DIR}" \ + --pkg-release "${PKG_RELEASE}" \ + --channel "${CHANNEL}" # 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' }} + if: ${{ inputs.publish && matrix.package_type == 'rpm' }} env: PKG_SIGNING_KEY: ${{ secrets.signing_key }} - run: ./package/sign_rpm.sh "${BUILD_DIR}" + run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -126,4 +132,8 @@ jobs: NEXUS_URL: ${{ inputs.nexus_url }} NEXUS_USERNAME: ${{ secrets.remote_username }} NEXUS_PASSWORD: ${{ secrets.remote_password }} - run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" + run: | + ./package/publish_pkg.py \ + --channel "${CHANNEL}" \ + --package-dir "${BUILD_DIR}" \ + --nexus-url "${NEXUS_URL}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5e69759fd..f223ab1684 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,11 +82,27 @@ repos: - id: prettier args: [--end-of-line=auto] + # Scoped to package/: the rest of the repo's Python has pre-existing findings, + # so widening these is its own change. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 7c55798a78262d14b2074abf623d8a992ebb70d4 # frozen: v0.16.2 + hooks: + - id: ruff-check + args: [--fix] + files: ^package/.*\.py$ + - repo: https://github.com/psf/black-pre-commit-mirror rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1 hooks: - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0 + hooks: + - id: mypy + args: [--strict] + files: ^package/.*\.py$ + - repo: https://github.com/scop/pre-commit-shfmt rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1 hooks: diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index bee7b15791..c454f487dc 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -1,7 +1,7 @@ #[===================================================================[ Linux packaging support: 'package' target. - The packaging script (package/build_pkg.sh) installs to FHS-standard + The packaging script (package/build_pkg.py) installs to FHS-standard paths (/usr/bin, /etc/xrpld, etc.) regardless of CMAKE_INSTALL_PREFIX, so no prefix guard is needed here. #]===================================================================] @@ -38,19 +38,19 @@ if(NOT TARGET validator-keys) return() endif() -set(package_env - SRC_DIR=${CMAKE_SOURCE_DIR} - BUILD_DIR=${CMAKE_BINARY_DIR} - PKG_RELEASE=${pkg_release} -) +if(DPKG_BUILDPACKAGE_EXECUTABLE) + set(pkg_type deb) +else() + set(pkg_type rpm) +endif() add_custom_target( package COMMAND - ${CMAKE_COMMAND} -E env ${package_env} - ${CMAKE_SOURCE_DIR}/package/build_pkg.sh + ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type} + --build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release} WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys - COMMENT "Building Linux package (deb/rpm inferred from host tooling)" + COMMENT "Building Linux ${pkg_type} package" VERBATIM ) diff --git a/package/README.md b/package/README.md index 54b1e57204..04f4db2ea5 100644 --- a/package/README.md +++ b/package/README.md @@ -8,9 +8,9 @@ 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) + build_pkg.py Staging and build script (called by the CMake `package` target and CI) + sign_rpm.py Signs the built RPMs (called by CI when publishing) + publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -28,8 +28,9 @@ Packaging targets and their container images are declared in under `package_configs`, one entry per distro. Today only `linux/amd64` is emitted. Each entry pins its full container image in an `image` field; to move to a new image, edit that field and both CI and local builds pick it up. The -package format (deb or rpm) is inferred at build time from the container's -package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). +entry also declares the format that image builds in a `package_type` field, +which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in +step. | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | @@ -51,10 +52,10 @@ Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from `package_configs` in `linux.json` (via `generate.py --packaging`) and fans out one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` -binary artifacts and runs in that distro's container, so the package format -follows from the container's package manager. The packaging script derives the -package version from the downloaded binary's `xrpld --version` output; no CMake -configure or build step is needed inside the packaging job. +binary artifacts and runs in that distro's container, building the format its +`package_type` declares. The packaging script derives the package version from +the downloaded binary's `xrpld --version` output; no CMake configure or build +step is needed inside the packaging job. The binaries come from the `debian` and `rhel` build configurations in `linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the @@ -75,9 +76,8 @@ The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash # From the repo root. Each distro's container image is the `image` field of its -# package_configs entry in linux.json; the package format is inferred from the -# container's package manager. Example for the rpm-producing image (use -# .package_configs.debian[0].image for the deb image): +# package_configs entry in linux.json. Example for the rpm-producing image (use +# .package_configs.debian[0].image and --package-type deb for the other one): IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json) PKG_RELEASE=1 @@ -86,7 +86,7 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "${IMAGE}" \ - ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" + ./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}" # Output: # build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) @@ -113,12 +113,12 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL The `cmake/XrplPackaging.cmake` module defines the `package` target only if at least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target -builds both binaries before packaging. `build_pkg.sh` then infers the package -format from the host's package manager. The packaging script installs to +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. -The package version is not a CMake input on this path: `build_pkg.sh` derives it +The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. @@ -126,7 +126,7 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`. Packages are published to the XRPLF repositories on Sonatype Nexus at `https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.sh` maps that channel to its repositories: +the event, and `publish_pkg.py` maps that channel to its repositories: | Event | Version | Channel | DEB repository | RPM upload repository | | ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- | @@ -162,7 +162,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing repository sits behind a `rpm-` yum group repository whose metadata Nexus signs. Uploads go to the hosted repository; clients point at the group and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs - themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients + themselves, so `sign_rpm.py` signs them before they are uploaded, and clients verify them with `gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. @@ -172,20 +172,20 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - The `develop` repositories gain a package per push, so they need a cleanup policy to stay bounded; tagged channels publish each version once. -## How `build_pkg.sh` works +## How `build_pkg.py` works -`build_pkg.sh` derives the `xrpld` software version from +`build_pkg.py` derives the `xrpld` software version from `${BUILD_DIR}/xrpld --version` in both package formats. The binary's version is already SemVer-validated by `BuildInfo`. -`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or +`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or `3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before the final release. If that normalized package version still contains `-`, packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as the upstream/revision separator. `pkg_version` is the normalized package metadata version derived inside -`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release +`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. `PKG_RELEASE` is a different value: the package release iteration for that @@ -203,35 +203,39 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the channel passed as `--channel` -(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build -metadata on a final release such as `3.2.0+abc123`, are both rejected. +`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it +from the build host, so the RHEL image can track a newer release without +changing what the packages claim to target. + +The Debian changelog entry carries the channel passed as `--channel`, +defaulting to `unstable`. An unsupported pre-release, and build metadata on a +final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. -The package format (`deb` or `rpm`) is inferred from the host's package -manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those -fail early. +The package format is `--package-type`, either `deb` or `rpm`. It is required, +so a job never silently builds the wrong format for the image it runs in; the +matching build tool still has to be on PATH. -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`, `PKG_RELEASE` and -`PKG_CHANNEL` via env and lets the script use defaults for the rest. +Every input is a named argument. CMake passes `--package-type`, `--build-dir` +and `--pkg-release`; CI adds `--channel`. The repository root is not an argument +at all: the script reads it from its own location. Only secrets stay in the +environment, so they never reach the process list -- `PKG_SIGNING_KEY` for +`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`. -Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +Signing is not part of this script. `sign_rpm.py` 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 +It resolves the build directory to an absolute path, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, and shared support files into the staging area, and invokes the platform build -tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging -environment; a missing or non-runnable one fails early. That runtime check is -what catches a binary still linked against the Nix store's ELF loader (see +tool. Both binaries must be present in the build directory and must run in the +packaging environment; a missing or non-runnable one fails early. That runtime +check is what catches a binary still linked against the Nix store's ELF loader (see `patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM @@ -277,10 +281,9 @@ lintian -I debbuild/*.deb ## Reproducibility -`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit -time, or the current time outside a git tree, and exports it (override with -`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file -modification times to it via `%build_mtime_policy`. The remaining variables +`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time and +exports it; the RPM spec clamps file modification times to it via +`%build_mtime_policy`. The remaining variables below further improve reproducibility but are _not_ set by the script — export them yourself if needed: diff --git a/package/build_pkg.py b/package/build_pkg.py new file mode 100755 index 0000000000..28835d1ccd --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries. + +The build tool for the chosen format has to be on PATH, so this runs in the +vanilla distro image that matches it. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import textwrap +from datetime import datetime, timezone +from pathlib import Path + +# This script lives in the repository it packages. +SRC_DIR = Path(__file__).resolve().parents[1] + +PRE_RELEASE = re.compile(r"^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$") + +# Files both packaging systems consume, staged under the same names. +STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") +STAGED_FROM_SRC = { + "cfg/xrpld-example.cfg": "xrpld.cfg", + "cfg/validators-example.txt": "validators.txt", + "LICENSE.md": "LICENSE.md", + "README.md": "README.md", +} +STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") + + +def run(*command: object, cwd: Path | None = None) -> None: + """Echo a command and run it.""" + argv = [str(part) for part in command] + print("+ " + " ".join(argv), flush=True) + subprocess.run(argv, check=True, cwd=cwd) + + +def capture(*command: object) -> str: + """Run a command and return its stdout, stripped.""" + argv = [str(part) for part in command] + # stderr is left alone so a failing command explains itself. + return subprocess.run( + argv, stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def package_version(reported: str) -> str: + """Normalise a reported version into one the package formats accept. + + A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before + the final 3.2.0; a no-op for a final release. + """ + base, _, pre_release = reported.partition("-") + version = f"{base}~{pre_release}" if pre_release else base + + # BuildInfo already SemVer-validates the version. Packaging adds one narrower + # constraint: after normalisation the version must not contain '-', because + # RPM forbids it in Version and Debian reads it as the revision separator. + assert "-" not in version, ( + f"unsupported version {reported!r}: {version!r} cannot contain '-'. " + "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." + ) + assert pre_release or "+" not in reported, ( + f"unsupported version {reported!r}: " + "build metadata is only supported on bN/rcN pre-releases." + ) + assert not pre_release or PRE_RELEASE.match(pre_release), ( + f"unsupported pre-release {pre_release!r}: use bN or rcN, " + "e.g. 3.2.0-b1 or 3.2.0-rc2." + ) + return version + + +def read_version(xrpld: Path) -> str: + """Read the version from the binary that is about to be packaged.""" + fields = capture(xrpld, "--version").partition("\n")[0].split() + assert len(fields) >= 3, f"cannot read a version from {xrpld} --version" + return fields[2] + + +def check_binaries(build_dir: Path) -> None: + """Fail unless the binaries and their notices are present and runnable.""" + missing = [ + name + for name in ("xrpld", "validator-keys") + if not os.access(build_dir / name, os.X_OK) + ] + assert not missing, ( + f"missing or not executable in {build_dir}: {' '.join(missing)}. " + "Both binaries come from a single CMake build directory configured with " + "-Dxrpld=ON -Dvalidator_keys=ON." + ) + + # No package goes out without the attribution. + notice = build_dir / "validator-keys-LICENSE" + assert notice.is_file(), ( + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " + "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + ) + + # Catches a binary still pointing at the Nix store's ELF loader, since + # packaging runs in a vanilla distro container. + capture(build_dir / "validator-keys", "--version") + + +def source_date_epoch() -> int: + """The last commit's timestamp.""" + # git refuses to read a checkout owned by another user, which is what a CI + # container or a bind mount hands it. + return int( + capture( + "git", + "-c", + f"safe.directory={SRC_DIR}", + "-C", + SRC_DIR, + "log", + "-1", + "--format=%ct", + ) + ) + + +def stage_common(build_dir: Path, dest: Path) -> None: + """Copy everything both packaging systems consume into dest.""" + dest.mkdir(parents=True, exist_ok=True) + + for name in STAGED_FROM_BUILD: + shutil.copy2(build_dir / name, dest / name) + for source, name in STAGED_FROM_SRC.items(): + shutil.copy2(SRC_DIR / source, dest / name) + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: + """Stage the spec and its sources, then build the binary RPMs.""" + topdir = build_dir / "rpmbuild" + for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (topdir / name).mkdir(parents=True, exist_ok=True) + + spec = topdir / "SPECS" / "xrpld.spec" + shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) + stage_common(build_dir, topdir / "SOURCES") + + run( + "rpmbuild", + "-bb", + "--define", + f"_topdir {topdir}", + "--define", + f"pkg_version {version}", + "--define", + f"pkg_release {pkg_release}", + # The image tracks the newest distro, but the packages target el9. + "--define", + "dist .el9", + spec, + ) + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + + # debhelper picks these up from debian/ automatically. + for name in STAGED_UNITS: + shutil.copy2(staging / name, staging / "debian" / name) + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + xrpld ({version}-{pkg_release}) {channel}; urgency=medium + * Release {reported}. + + -- XRPL Foundation {date} + """) + (staging / "debian" / "changelog").write_text(changelog) + + (staging / "debian" / "rules").chmod(0o755) + + run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package-type", + required=True, + choices=("deb", "rpm"), + help="the package format to build", + ) + parser.add_argument( + "--build-dir", + type=Path, + default=Path("build"), + help="directory holding the xrpld and validator-keys binaries (default: %(default)s)", + ) + parser.add_argument( + "--pkg-release", + default="1", + help="package release iteration (default: %(default)s)", + ) + parser.add_argument( + "--channel", + default="unstable", + help="release channel, written to debian/changelog (default: %(default)s)", + ) + args = parser.parse_args() + package_type: str = args.package_type + build_dir: Path = args.build_dir.resolve() + pkg_release: str = args.pkg_release + channel: str = args.channel + + assert build_dir.is_dir(), ( + f"build directory not found: {build_dir}. Build the binaries before " + "packaging, or point --build-dir at the directory holding them." + ) + + check_binaries(build_dir) + reported = read_version(build_dir / "xrpld") + version = package_version(reported) + epoch = source_date_epoch() + + # rpmbuild and dpkg-buildpackage both honour this for file timestamps. + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + + # Remove both build trees, because a package left from an earlier build would + # otherwise be picked up and published alongside this one. + for tree in ("debbuild", "rpmbuild"): + shutil.rmtree(build_dir / tree, ignore_errors=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index cca3be7248..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from the pre-built xrpld and validator-keys -# binaries. -# -# Flags override env vars; env vars override defaults. - -usage() { - cat <<'EOF' -Usage: build_pkg.sh [options] - -Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding the - xrpld and validator-keys - binaries [BUILD_DIR; default: ${PWD}/build] - --pkg-release N package release iteration [PKG_RELEASE; default: 1] - --channel NAME release channel, written - to debian/changelog [PKG_CHANNEL; default: unstable] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] - -h, --help show this help and exit -EOF -} - -need_arg() { - if [[ $# -lt 2 || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 2 - fi -} - -# Seed from env. CLI parsing below overrides these directly. -SRC_DIR="${SRC_DIR:-}" -BUILD_DIR="${BUILD_DIR:-}" -PKG_RELEASE="${PKG_RELEASE:-1}" -PKG_CHANNEL="${PKG_CHANNEL:-unstable}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --src-dir) - need_arg "$@" - SRC_DIR="$2" - shift 2 - ;; - --build-dir) - need_arg "$@" - BUILD_DIR="$2" - shift 2 - ;; - --pkg-release) - need_arg "$@" - PKG_RELEASE="$2" - shift 2 - ;; - --channel) - need_arg "$@" - PKG_CHANNEL="$2" - shift 2 - ;; - --source-date-epoch) - need_arg "$@" - SOURCE_DATE_EPOCH="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="${BUILD_DIR:-${PWD}/build}" -if [[ ! -d "${BUILD_DIR}" ]]; then - echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -validator_keys_binary="${BUILD_DIR}/validator-keys" - -# Report both binaries at once: they share a single BUILD_DIR, so telling the -# reader to point it at one of them in isolation is advice they cannot follow. -missing=() -[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) -[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) - -if [[ ${#missing[@]} -gt 0 ]]; then - echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 - echo "Both binaries come from a single CMake build directory configured with" >&2 - echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 - echo "directory." >&2 - exit 1 -fi - -# Shipping validator-keys means shipping its notice, so treat it as required -# rather than letting a package go out without the attribution. -validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" -if [[ ! -f "${validator_keys_license}" ]]; then - echo "build_pkg.sh: missing ${validator_keys_license}." >&2 - echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 - echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 - exit 1 -fi - -# The binary must also *run* here. Packaging happens in a vanilla distro -# container, so this is what catches a binary still pointing at the Nix store's -# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is -# covered implicitly by the version query below. -if ! "${validator_keys_binary}" --version >/dev/null; then - echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 - exit 1 -fi - -xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')" - -if [[ -z "${xrpld_version}" ]]; then - echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2 - exit 1 -fi - -# The version as the package formats consume it: identical to xrpld_version -# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before -# the final 3.2.0; a no-op for a final release. Lowercase = derived internally, -# not an input (cf. pkg_type). -pkg_version="${xrpld_version}" -pre_release="" -if [[ "${xrpld_version}" == *-* ]]; then - pre_release="${xrpld_version#*-}" - pkg_version="${xrpld_version%%-*}~${pre_release}" -fi - -# BuildInfo already SemVer-validates the binary's version. Packaging adds one -# narrower constraint: after pre-release normalization, the package version must -# not contain '-' because RPM forbids it in Version and Debian uses it as the -# upstream/revision separator. -if [[ "${pkg_version}" == *-* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Package version '${pkg_version}' cannot contain '-'." >&2 - echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Build metadata is only supported on bN/rcN pre-releases." >&2 - exit 1 -fi - -if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if command -v apt-get >/dev/null 2>&1; then - pkg_type=deb -elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then - pkg_type=rpm -else - echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2 - exit 1 -fi - -if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then - if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH="$(date +%s)" - fi -fi - -export SOURCE_DATE_EPOCH -CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" - -SHARED="${SRC_DIR}/package/shared" -DEBIAN_DIR="${SRC_DIR}/package/debian" - -# Stage files that both packaging systems consume using the same filenames. -stage_common() { - local dest="$1" - mkdir -p "${dest}" - - cp "${xrpld_binary}" "${dest}/xrpld" - cp "${validator_keys_binary}" "${dest}/validator-keys" - cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" - - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" -} - -build_rpm() { - local topdir="${BUILD_DIR}/rpmbuild" - mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - - cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" - stage_common "${topdir}/SOURCES" - - set -x - rpmbuild -bb \ - --define "_topdir ${topdir}" \ - --define "pkg_version ${pkg_version}" \ - --define "pkg_release ${PKG_RELEASE}" \ - "${topdir}/SPECS/xrpld.spec" -} - -build_deb() { - local staging="${BUILD_DIR}/debbuild/source" - mkdir -p "${staging}" - - stage_common "${staging}" - cp -r "${DEBIAN_DIR}" "${staging}/debian" - - cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service" - cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers" - cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" - cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - - # Debian version is [~
]-.
-    cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
-EOF
-
-    chmod +x "${staging}/debian/rules"
-
-    set -x
-    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
-}
-
-# Remove both build directories, because a package left from an earlier build
-# would otherwise be picked up and published alongside this one.
-rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
-
-"build_${pkg_type}"
diff --git a/package/publish_pkg.py b/package/publish_pkg.py
new file mode 100755
index 0000000000..2c320a595a
--- /dev/null
+++ b/package/publish_pkg.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Publish the packages built by build_pkg.py to the XRPLF repositories on Nexus.
+
+RPMs are uploaded to the hosted repository, but yum clients install from the
+'rpm-' group repository in front of it, which serves signed metadata.
+
+NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
+credentials never reach the process list.
+"""
+
+import argparse
+import base64
+import os
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+SUFFIXES = (".deb", ".ddeb", ".rpm")
+
+# No progress for this long ends an attempt. urlopen applies the timeout per
+# socket operation, so a stalled transfer fails while a merely slow one carries
+# on -- the debuginfo package is large enough for that distinction to matter.
+STALL_TIMEOUT = 300
+
+ATTEMPTS = 4
+RETRY_DELAY = 5
+
+
+def build_opener() -> urllib.request.OpenerDirector:
+    """An opener with no redirect handler, so a 3xx raises instead of being followed.
+
+    A redirected upload is silently downgraded to a GET, turning it into a no-op
+    that still answers 200.
+    """
+    opener = urllib.request.OpenerDirector()
+    opener.add_handler(urllib.request.HTTPHandler())
+    opener.add_handler(urllib.request.HTTPSHandler())
+    opener.add_handler(urllib.request.HTTPErrorProcessor())
+    opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+    return opener
+
+
+def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None:
+    """Send one package, retrying only what is worth retrying.
+
+    A 4xx is a deterministic rejection, so it is reported at once rather than
+    re-sending the whole body three more times. Nexus explains what it rejected
+    in the response body, so that body is always surfaced.
+    """
+    opener = build_opener()
+
+    for attempt in range(1, ATTEMPTS + 1):
+        try:
+            with package.open("rb") as body:
+                request = urllib.request.Request(
+                    url,
+                    data=body,
+                    method=method,
+                    headers={**headers, "Content-Length": str(package.stat().st_size)},
+                )
+                opener.open(request, timeout=STALL_TIMEOUT)
+            return
+        except urllib.error.HTTPError as error:
+            detail = error.read().decode(errors="replace").strip()
+            reason = f"HTTP {error.code}: {detail}"
+            retryable = error.code >= 500
+        except (urllib.error.URLError, OSError) as error:
+            reason = str(error)
+            retryable = True
+
+        assert (
+            retryable and attempt < ATTEMPTS
+        ), f"upload of {package.name} failed: {reason}"
+        print(f"    attempt {attempt} failed ({reason}), retrying")
+        time.sleep(RETRY_DELAY)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--channel",
+        required=True,
+        help="release channel, selecting the deb- and rpm--hosted repositories",
+    )
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--nexus-url",
+        default="https://packages.xrplf.org",
+        help="the Nexus instance to publish to (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="list the uploads without performing them",
+    )
+    args = parser.parse_args()
+    channel: str = args.channel
+    package_dir: Path = args.package_dir
+    nexus_url: str = args.nexus_url
+    dry_run: bool = args.dry_run
+
+    nexus = nexus_url.rstrip("/")
+    deb_repo = f"deb-{channel}"
+    rpm_repo = f"rpm-{channel}-hosted"
+
+    auth: dict[str, str] = {}
+    if not dry_run:
+        username = os.environ.get("NEXUS_USERNAME")
+        password = os.environ.get("NEXUS_PASSWORD")
+        assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
+        token = base64.b64encode(f"{username}:{password}".encode()).decode()
+        auth = {"Authorization": f"Basic {token}"}
+
+    packages = sorted(
+        path
+        for path in package_dir.rglob("*")
+        if path.is_file() and path.suffix in SUFFIXES
+    )
+    # Uploading nothing would otherwise look like a successful publish.
+    assert packages, f"no packages found in {package_dir}"
+
+    print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
+    for package in packages:
+        if package.suffix == ".rpm":
+            # yum repositories are addressed by path, and the arch comes from
+            # the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
+            destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
+            url = f"{nexus}/repository/{destination}/{package.name}"
+            method, content_type = "PUT", "application/octet-stream"
+        else:
+            # A raw body with a multipart Content-Type, POSTed to the repository
+            # root, is the documented upload for a hosted apt repository:
+            # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+            destination = deb_repo
+            url = f"{nexus}/repository/{destination}/"
+            method, content_type = "POST", "multipart/form-data"
+
+        print(f"  {package.name} -> {destination}")
+        if not dry_run:
+            upload(url, method, {"Content-Type": content_type, **auth}, package)
+
+    verb = "would be published" if dry_run else "published"
+    print(f"{len(packages)} package(s) {verb}.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
deleted file mode 100755
index 8ea9b189f4..0000000000
--- a/package/publish_pkg.sh
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
-# repositories on Sonatype Nexus.
-#
-# Usage: publish_pkg.sh  [package-dir]
-#
-#   channel      release channel, selecting the 'deb-' and
-#                'rpm--hosted' repositories
-#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
-#                default)
-#
-# RPMs are uploaded to the hosted repository, but yum clients install from the
-# 'rpm-' group repository in front of it, which serves signed metadata.
-#
-# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
-# instance, and DRY_RUN=1 lists the uploads without performing them.
-
-channel="${1:-}"
-pkg_dir="${2:-build}"
-nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
-
-if [[ -z "${channel}" ]]; then
-    echo "usage: publish_pkg.sh  [package-dir]" >&2
-    exit 2
-fi
-
-deb_repo="deb-${channel}"
-rpm_repo="rpm-${channel}-hosted"
-
-if [[ -z "${DRY_RUN:-}" ]]; then
-    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
-fi
-
-# Deliberate curl choices:
-#
-#   - no --fail, which would hide the response body where Nexus explains what it
-#     rejected
-#   - no --location, since curl downgrades a redirected POST to GET and turns an
-#     upload into a no-op that still answers 200
-#   - credentials on stdin, to keep them out of the process list
-upload() {
-    local url="$1"
-    shift
-    [[ -z "${DRY_RUN:-}" ]] || return 0
-
-    local body code status=0
-    body="$(mktemp)"
-    code="$(
-        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
-            curl \
-                --config - \
-                --silent \
-                --show-error \
-                --retry 3 \
-                --retry-delay 5 \
-                --retry-all-errors \
-                --output "${body}" \
-                --write-out '%{http_code}' \
-                "$@" \
-                "${url}"
-    )" || status=$?
-
-    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
-        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
-        cat "${body}" >&2
-        echo >&2
-        rm -f "${body}"
-        exit 1
-    fi
-
-    rm -f "${body}"
-}
-
-echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
-
-count=0
-while IFS= read -r -d '' file; do
-    name="${file##*/}"
-    case "${name}" in
-        # A raw body with a multipart Content-Type, POSTed to the repository root,
-        # is the documented upload for a hosted apt repository:
-        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
-        *.deb | *.ddeb)
-            echo "  ${name} -> ${deb_repo}"
-            upload "${nexus_url}/repository/${deb_repo}/" \
-                --header 'Content-Type: multipart/form-data' \
-                --data-binary "@${file}"
-            ;;
-        # yum repositories are addressed by path; the arch comes from the name.
-        *.rpm)
-            arch="${name%.rpm}"
-            arch="${arch##*.}"
-            echo "  ${name} -> ${rpm_repo}/${arch}"
-            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
-                --upload-file "${file}"
-            ;;
-    esac
-    count=$((count + 1))
-done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
-
-# Uploading nothing would otherwise look like a successful publish.
-if [[ ${count} -eq 0 ]]; then
-    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-echo "${count} package(s) ${DRY_RUN:+would be }published."
diff --git a/package/sign_rpm.py b/package/sign_rpm.py
new file mode 100755
index 0000000000..05c719b710
--- /dev/null
+++ b/package/sign_rpm.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Sign the RPMs built by build_pkg.py.
+
+Nexus signs the yum repository metadata (via the 'rpm-' group
+repository), but never the packages themselves, so they carry their own
+signature. Clients verify the packages with gpgcheck=1 and the metadata with
+repo_gpgcheck=1.
+
+The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+but apt does not verify them by default and trusts the repository metadata,
+which Nexus signs, instead.
+
+PKG_SIGNING_KEY is read from the environment, so the key never reaches the
+process list.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
+# so both are queried; checking only the first would reject a signed package.
+SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
+UNSIGNED = "(none)(none)"
+
+
+def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
+    """Run gpg against a throwaway keyring and return its stdout."""
+    return subprocess.run(
+        ["gpg", "--batch", "--quiet", *args],
+        input=stdin,
+        # stderr is left alone so a failing gpg explains itself.
+        stdout=subprocess.PIPE,
+        text=True,
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    ).stdout
+
+
+def import_key(gnupghome: Path, key: str) -> str:
+    """Import the armoured private key and return its fingerprint."""
+    gpg(gnupghome, "--import", stdin=key)
+
+    records = [
+        line.split(":")
+        for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
+    ]
+    # Exactly one, so the fingerprint picked below is not a guess.
+    secrets = [record for record in records if record[0] == "sec"]
+    assert (
+        len(secrets) == 1
+    ), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
+
+    # The first fingerprint belongs to the primary key; subkeys follow.
+    fingerprints = [record[9] for record in records if record[0] == "fpr"]
+    assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
+    return fingerprints[0]
+
+
+def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
+    """Attach a signature to every RPM in one rpmsign invocation."""
+    subprocess.run(
+        [
+            "rpmsign",
+            "--define",
+            f"_gpg_name {fingerprint}",
+            # Loopback pinentry: the key is unattended, so there is no tty to
+            # prompt on.
+            "--define",
+            "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
+            "--addsign",
+            *(str(rpm) for rpm in rpms),
+        ],
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    )
+
+
+def verify(rpms: list[Path]) -> None:
+    """Fail unless every RPM now carries a signature.
+
+    rpmsign can exit 0 having attached nothing, and an unsigned package is only
+    rejected later, on the installing machine.
+    """
+    for rpm in rpms:
+        signature = subprocess.run(
+            ["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
+            stdout=subprocess.PIPE,
+            text=True,
+            check=True,
+        ).stdout.strip()
+        assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help="searched recursively for *.rpm (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    package_dir: Path = args.package_dir
+
+    rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
+    # Signing nothing would otherwise look like a successful signing.
+    assert rpms, f"no RPMs found in {package_dir}"
+
+    key = os.environ.get("PKG_SIGNING_KEY")
+    assert key, "PKG_SIGNING_KEY is required"
+
+    # The keyring holds an unencrypted private key, so it goes even if signing
+    # fails.
+    with tempfile.TemporaryDirectory() as tmp:
+        gnupghome = Path(tmp)
+        fingerprint = import_key(gnupghome, key)
+        print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
+        sign(gnupghome, rpms, fingerprint)
+        verify(rpms)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
deleted file mode 100755
index 250e806dd7..0000000000
--- a/package/sign_rpm.sh
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata
-# (via the 'rpm-' group repository), but never the packages themselves,
-# so they carry their own signature. Clients verify the packages with gpgcheck=1
-# and the metadata with repo_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.
-#
-# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
-# but apt does not verify them by default and trusts the repository metadata,
-# which Nexus signs, instead.
-
-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