diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild
new file mode 100644
index 0000000..c928cea
--- /dev/null
+++ b/Dockerfile.rpmbuild
@@ -0,0 +1,102 @@
+# syntax=docker/dockerfile:1
+FROM fedora:42
+
+RUN set -eux; \
+ dnf -y update; \
+ dnf -y install \
+ rpm-build \
+ rpmdevtools \
+ redhat-rpm-config \
+ gcc \
+ make \
+ findutils \
+ tar \
+ gzip \
+ rsync \
+ python3 \
+ python3-devel \
+ python3-setuptools \
+ python3-wheel \
+ pyproject-rpm-macros \
+ python3-rpm-macros \
+ python3-yaml \
+ python3-paramiko \
+ openssl-devel \
+ python3-poetry-core ; \
+ dnf -y clean all
+
+# Build runner script (copies repo, tars, runs rpmbuild)
+RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+SRC="${SRC:-/src}"
+WORKROOT="${WORKROOT:-/work}"
+OUT="${OUT:-/out}"
+DEPS_DIR="${DEPS_DIR:-/deps}"
+
+# Install jinjaturtle from local rpm
+# Filter out .src.rpm and debug* subpackages if present.
+if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
+ mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)')
+ if [ "${#rpms[@]}" -gt 0 ]; then
+ echo "Installing dependency RPMs from ${DEPS_DIR}:"
+ printf ' - %s\n' "${rpms[@]}"
+ dnf -y install "${rpms[@]}"
+ dnf -y clean all
+ else
+ echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
+ fi
+else
+ echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(jinjaturtle)," >&2
+ echo " mount your jinjaturtle RPM directory as -v
:/deps" >&2
+fi
+
+mkdir -p "${WORKROOT}" "${OUT}"
+WORK="${WORKROOT}/src"
+rm -rf "${WORK}"
+mkdir -p "${WORK}"
+
+rsync -a --delete \
+ --exclude '.git' \
+ --exclude '.venv' \
+ --exclude 'dist' \
+ --exclude 'build' \
+ --exclude '__pycache__' \
+ --exclude '.pytest_cache' \
+ --exclude '.mypy_cache' \
+ "${SRC}/" "${WORK}/"
+
+cd "${WORK}"
+
+# Determine version from pyproject.toml unless provided
+if [ -n "${VERSION:-}" ]; then
+ ver="${VERSION}"
+else
+ ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')"
+fi
+
+TOPDIR="${WORKROOT}/rpmbuild"
+mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
+
+tarball="${TOPDIR}/SOURCES/enroll-${ver}.tar.gz"
+tar -czf "${tarball}" --transform "s#^#enroll/#" .
+
+spec_src="rpm/enroll.spec"
+
+cp -v "${spec_src}" "${TOPDIR}/SPECS/enroll.spec"
+
+rpmbuild -ba "${TOPDIR}/SPECS/enroll.spec" \
+ --define "_topdir ${TOPDIR}" \
+ --define "upstream_version ${ver}"
+
+shopt -s nullglob
+cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true
+cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true
+echo "Artifacts copied to ${OUT}"
+EOF
+
+RUN chmod +x /usr/local/bin/build-rpm
+
+WORKDIR /work
+ENTRYPOINT ["/usr/local/bin/build-rpm"]
diff --git a/README.md b/README.md
index 00f9d98..5a0db91 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,6 @@
**enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
-It aims to be **optimistic and noninteractive**:
- Detects packages that have been installed.
- Detects Debian package ownership of `/etc` files using dpkg’s local database.
- Captures config that has **changed from packaged defaults** (dpkg conffile hashes + package md5sums when available).
@@ -26,9 +25,10 @@ It aims to be **optimistic and noninteractive**:
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
-Additionally:
+Additionally, some other functionalities exist:
- **Diff**: compare two harvests and report what changed (packages/services/users/files) since the previous snapshot.
+- **Single-shot mode**: run both harvest and manifest at once.
---
diff --git a/poetry.lock b/poetry.lock
index 1f2948d..0a90711 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -923,4 +923,4 @@ zstd = ["backports-zstd (>=1.0.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
-content-hash = "c3466a6595a9822763431a6dff0c7f835407a2591b92d5995592f8e6802c774a"
+content-hash = "20623104a1a5f4c6d4aaa759f25b2591d5de345d1464e727eb4140a6ef9a5b6e"
diff --git a/pyproject.toml b/pyproject.toml
index 541eded..3079404 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,8 +10,8 @@ repository = "https://git.mig5.net/mig5/enroll"
[tool.poetry.dependencies]
python = "^3.10"
-pyyaml = "^6.0.3"
-paramiko = "^4.0.0"
+pyyaml = "^6"
+paramiko = ">=3.5"
[tool.poetry.scripts]
enroll = "enroll.cli:main"
diff --git a/release.sh b/release.sh
index fe99a52..fdbe771 100755
--- a/release.sh
+++ b/release.sh
@@ -42,3 +42,34 @@ for dist in ${DISTS[@]}; do
debfile=$(ls -1 dist/${release}/*.deb)
reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}"
done
+
+# RPM
+sudo apt-get -y install createrepo-c rpm
+docker build -f Dockerfile.rpmbuild -t enroll:f42 --progress=plain .
+docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/jinjaturtle/dist/rpm":/deps:ro enroll:f42
+sudo chown -R "${USER}" "$PWD/dist"
+
+REPO_ROOT="${HOME}/git/repo_rpm"
+RPM_REPO="${REPO_ROOT}/rpm/x86_64"
+BUILD_OUTPUT="${HOME}/git/enroll/dist"
+REMOTE="letessier.mig5.net:/opt/repo_rpm"
+KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
+
+echo "==> Updating RPM repo..."
+mkdir -p "$RPM_REPO"
+
+for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
+ rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
+done
+
+cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
+
+createrepo_c "$RPM_REPO"
+
+echo "==> Signing repomd.xml..."
+qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
+
+echo "==> Syncing repo to server..."
+rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/"
+
+echo "Done!"
diff --git a/rpm/enroll.spec b/rpm/enroll.spec
new file mode 100644
index 0000000..403d6da
--- /dev/null
+++ b/rpm/enroll.spec
@@ -0,0 +1,47 @@
+%global upstream_version 0.1.3
+
+Name: enroll
+Version: %{upstream_version}
+Release: 1%{?dist}.enroll1
+Summary: Enroll a server's running state retrospectively into Ansible.
+
+License: GPL-3.0-or-later
+URL: https://git.mig5.net/mig5/enroll
+Source0: %{name}-%{version}.tar.gz
+
+BuildArch: noarch
+
+BuildRequires: pyproject-rpm-macros
+BuildRequires: python3-devel
+BuildRequires: python3-poetry-core
+
+Requires: python3-yaml
+Requires: python3-paramiko
+
+# Make sure private repo dependency is pulled in by package name as well.
+Recommends: jinjaturtle
+
+%description
+Enroll a server's running state retrospectively into Ansible.
+
+%prep
+%autosetup -n enroll
+
+%generate_buildrequires
+%pyproject_buildrequires
+
+%build
+%pyproject_wheel
+
+%install
+%pyproject_install
+%pyproject_save_files enroll
+
+%files -f %{pyproject_files}
+%license LICENSE
+%doc README.md CHANGELOG.md
+%{_bindir}/enroll
+
+%changelog
+* Sat Dec 27 2025 Miguel Jacq - %{version}-%{release}
+- Initial RPM packaging for Fedora 42