🎁 .rpm Package Guide

A how-to manual: write a spec file for a finished Python game, build it as a normal user, check it with rpmlint, install, upgrade and downgrade it, sign it, serve it from a repository, and compare it with a .deb, with every command run for real.

Video: rpm Package Guide (Sysadmin video)
Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
rpmdev-setuptree
rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec
sudo dnf install ./cmd-hangman-1.0.0-2.el9.noarch.rpm

An .rpm is how Rocky Linux, RHEL, AlmaLinux and Fedora ship software. Unlike a .deb, it is built by a program (rpmbuild) following a recipe, the spec file. This guide writes that recipe for the Linux command hangman game from the series (first guide: Python Game: Build Linux Command Hangman), and then does what a careful packager does: checks the package with rpmlint, installs, upgrades and downgrades it, tests it on a machine that has nothing, signs it and serves it from a repository.

Every example ran for real on Rocky Linux 9.8 (rpm 4.16.1.3, rpmlint 1.11, Python 3.9.25), as an ordinary user. Rocky 9 is built from the sources of RHEL 9, so the steps apply to RHEL 9 and AlmaLinux 9 as well. The previous guide in the series builds the same game as a .deb for Ubuntu; the last section compares the two.

What an .rpm is, and what you need

An .rpm file is how Red Hat Enterprise Linux, Rocky Linux, AlmaLinux, Fedora, CentOS Stream and SUSE ship software. Like a .deb, it is an archive of files plus a header: name, version, what it needs, install scripts. The big difference is in how it is built. A .deb is built from a folder laid out like the finished system. An .rpm is built by a program, rpmbuild, from a recipe called a spec file and a tarball of the source. rpmbuild unpacks the source, runs the build steps from the recipe in a clean area, and collects the files you list into the package.

The recipe is the part you write, and it is what this guide is about. The program is the cmd-hangman game from the first guide in the series (Python Game: Build Linux Command Hangman). It is plain Python, so there is nothing to compile, and the package is "noarch": the same file works on every CPU type.

Every example below ran for real on Rocky Linux 9.8 (rpm 4.16.1.3, rpmlint 1.11, Python 3.9.25). Rocky 9 is rebuilt from the sources of Red Hat Enterprise Linux 9, so what works here works on RHEL 9 and AlmaLinux 9, and in practice on Fedora with minor differences. Building is done as an ordinary user, never as root: a mistake in a spec file runs as you, and a build as root can overwrite system files.

$ cat /etc/rocky-release
Rocky Linux release 9.8 (Blue Onyx)
$ rpm --version
RPM version 4.16.1.3
$ python3 --version
Python 3.9.25

Install the build tools

Three packages. rpm-build contains rpmbuild, rpmdevtools gives helpers such as rpmdev-setuptree and rpmdev-newspec, and rpmlint is the checker (optional, but the guide uses it):

$ sudo dnf -y install rpm-build rpmdevtools rpmlint 2>&1 | tail -4
Package rpmlint-1.11-19.el9.noarch is already installed.
Dependencies resolved.
Nothing to do.
Complete!
$ rpmbuild --version
RPM version 4.16.1.3

rpmbuild expects a fixed folder structure in your home directory. rpmdev-setuptree creates it:

$ rpmdev-setuptree
$ tree ~/rpmbuild
/home/tc/rpmbuild
├── BUILD
├── RPMS
├── SOURCES
├── SPECS
└── SRPMS

5 directories, 0 files

The folders, and what goes into each:

SOURCES   the tarballs and patches the recipe uses (what you put there)
SPECS     the recipe files, one per package
BUILD     the unpacked source, while it builds
BUILDROOT a fake root directory: %install puts the finished files here, as they will be on a real system
RPMS      the finished binary packages (one folder per architecture, noarch for ours)
SRPMS     the source package, which holds the spec file and the tarball and lets anyone rebuild it

The source tarball

rpm packages are built from a source archive with a name-version.tar.gz name, which unpacks into a folder called name-version. Both parts must match the Name and Version in the recipe, or the build stops (the troubleshooting section shows the error). Start with the game file. The first guide ended with hangman.py; copy it and give it the name of the command:

$ mkdir ~/rpm && cd ~/rpm
$ cp ~/hangman/hangman.py ~/rpm/cmd-hangman
$ ls -l ~/rpm
total 4
-rwxr-xr-x 1 tc tc 2185 Oct 30 23:58 cmd-hangman
$ head -1 ~/rpm/cmd-hangman
#!/usr/bin/env python3

Make the folder that the tarball unpacks into, put the program in it, and pack it. The -C option runs tar in the parent folder, so the paths inside the archive start with cmd-hangman-1.0.0/ and not with your home directory:

$ mkdir cmd-hangman-1.0.0
$ cp cmd-hangman cmd-hangman-1.0.0/
$ tar -czf ~/rpmbuild/SOURCES/cmd-hangman-1.0.0.tar.gz cmd-hangman-1.0.0
$ tar -tzf ~/rpmbuild/SOURCES/cmd-hangman-1.0.0.tar.gz
cmd-hangman-1.0.0/
cmd-hangman-1.0.0/cmd-hangman

The spec file

The spec file is the recipe. Save it in SPECS under the package name, ending in .spec. (rpmdev-newspec cmd-hangman writes a template with every section and comments, which is a good thing to read once; this guide writes the smallest useful file.)

$ cat ~/rpmbuild/SPECS/cmd-hangman.spec
Name:       cmd-hangman
Version:    1.0.0
Release:    1%{?dist}
Summary:    Linux command hangman for the terminal
License:    CC0-1.0
URL:        https://techcommune.org
Source0:    %{name}-%{version}.tar.gz
BuildArch:  noarch
Requires:   python3

%description
Guess the Linux command one letter at a time before the
stick figure is finished. A packaging tutorial game.

%prep
%setup -q

%build
# nothing to compile

%install
install -D -m 0755 cmd-hangman %{buildroot}%{_bindir}/cmd-hangman

%files
%{_bindir}/cmd-hangman

%changelog
* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-1
- First package

The header, one tag per line:

Name        the package name; must match the tarball name
Version     the version of the software itself
Release     the build number of this package of that version; %{?dist} adds .el9 on Rocky 9 and RHEL 9, .fc41 on Fedora
Summary     one line, no full stop
License     the licence of the software (CC0 is a public-domain dedication). The exact spelling matters to rpmlint: Rocky 9 and RHEL 9 use the older short names (GPLv2+, MIT, CC0), while Fedora has moved to SPDX identifiers such as GPL-2.0-or-later. The check below shows which one this system accepts
URL         the project home page
Source0     the tarball; %{name} and %{version} are replaced with the tags above
BuildArch   noarch: no compiled code, so one package for all CPUs
Requires    what must be installed. python3 is enough, because the program starts with #!/usr/bin/env python3

Then the sections. Each begins with a % keyword:

%description   a longer text for the package information
%prep          prepare the source. %setup -q unpacks Source0 into BUILD and enters the folder (-q = quiet)
%build         compile. Nothing to do for a script, so it holds only a comment
%install       copy the finished files into the fake root %{buildroot}. %{_bindir} expands to /usr/bin, so the program ends up as /usr/bin/cmd-hangman
%files         the list of what the package owns. A file that is installed but not listed here is an error
%changelog     the history: a star, the date and the person, the version-release, then bullet lines. The date format is strict (day-of-week, month, day, year)

The % names are macros. See what rpm makes of them:

$ rpm --eval '%{_bindir}'
/usr/bin
$ rpm --eval '%{_mandir}'
/usr/share/man
$ rpm --eval '%{?dist}'
.el9
$ rpmspec -q --qf '%{name}-%{version}-%{release}.%{arch}\n' ~/rpmbuild/SPECS/cmd-hangman.spec
cmd-hangman-1.0.0-1.el9.noarch

Build the package

rpmbuild -ba means "build all": the binary package and the source package. The last argument is the spec file. The output is long, because rpmbuild prints every step, so only the lines that matter are shown here: the macros that ran, and the files it wrote:

$ rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | grep -E "^(Executing|Wrote|Checking|Processing)"
Executing(%prep): /bin/sh -e /var/tmp/rpm-tmp.YrFSoq
Executing(%build): /bin/sh -e /var/tmp/rpm-tmp.yBL7No
Executing(%install): /bin/sh -e /var/tmp/rpm-tmp.zjBXrA
Processing files: cmd-hangman-1.0.0-1.el9.noarch
Checking for unpackaged file(s): /usr/lib/rpm/check-files /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64
Wrote: /home/tc/rpmbuild/SRPMS/cmd-hangman-1.0.0-1.el9.src.rpm
Wrote: /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-1.el9.noarch.rpm
Executing(%clean): /bin/sh -e /var/tmp/rpm-tmp.6dy1vM

Find the results:

$ find ~/rpmbuild/RPMS ~/rpmbuild/SRPMS -name '*.rpm'
/home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-1.el9.noarch.rpm
/home/tc/rpmbuild/SRPMS/cmd-hangman-1.0.0-1.el9.src.rpm

The binary package is in RPMS/noarch, the source package in SRPMS. The names are name-version-release.dist.architecture.rpm. Look at the whole build log once, to see the steps in order; this is also where you look when a build fails:

$ rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | sed -n '1,45p'
setting SOURCE_DATE_EPOCH=1793318400
Executing(%prep): /bin/sh -e /var/tmp/rpm-tmp.twiJtF
+ umask 022
+ cd /home/tc/rpmbuild/BUILD
+ cd /home/tc/rpmbuild/BUILD
+ rm -rf cmd-hangman-1.0.0
+ /usr/bin/gzip -dc /home/tc/rpmbuild/SOURCES/cmd-hangman-1.0.0.tar.gz
+ /usr/bin/tar -xof -
+ STATUS=0
+ '[' 0 -ne 0 ']'
+ cd cmd-hangman-1.0.0
+ /usr/bin/chmod -Rf a+rX,u+w,g-w,o-w .
+ RPM_EC=0
++ jobs -p
+ exit 0
Executing(%build): /bin/sh -e /var/tmp/rpm-tmp.l1Mh4h
+ umask 022
+ cd /home/tc/rpmbuild/BUILD
+ cd cmd-hangman-1.0.0
+ RPM_EC=0
++ jobs -p
+ exit 0
Executing(%install): /bin/sh -e /var/tmp/rpm-tmp.xGZWCF
+ umask 022
+ cd /home/tc/rpmbuild/BUILD
+ '[' /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64 '!=' / ']'
+ rm -rf /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64
++ dirname /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64
+ mkdir -p /home/tc/rpmbuild/BUILDROOT
+ mkdir /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64
+ cd cmd-hangman-1.0.0
+ install -D -m 0755 cmd-hangman /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.0-1.el9.x86_64/usr/bin/cmd-hangman
+ /usr/bin/find-debuginfo -j8 --strict-build-id -m -i --build-id-seed 1.0.0-1.el9 --unique-debug-suffix -1.0.0-1.el9.x86_64 --unique-debug-src-base cmd-hangman-1.0.0-1.el9.x86_64 --run-dwz --dwz-low-mem-die-limit 10000000 --dwz-max-die-limit 110000000 --remove-section .gnu.build.attributes -S debugsourcefiles.list /home/tc/rpmbuild/BUILD/cmd-hangman-1.0.0
+ '[' '%{buildarch}' = noarch ']'
+ QA_CHECK_RPATHS=1
+ case "${QA_CHECK_RPATHS:-}" in
+ /usr/lib/rpm/check-rpaths
+ /usr/lib/rpm/check-buildroot
+ /usr/lib/rpm/redhat/brp-ldconfig
+ /usr/lib/rpm/brp-compress
+ /usr/lib/rpm/redhat/brp-strip-lto /usr/bin/strip
+ /usr/lib/rpm/brp-strip-static-archive /usr/bin/strip
+ /usr/lib/rpm/redhat/brp-python-bytecompile '' 1 0
+ /usr/lib/rpm/brp-python-hardlink
+ /usr/lib/rpm/redhat/brp-mangle-shebangs

Look inside the package

Query commands with -p ask about a package file instead of an installed package. -qpi shows the information (the header, in the order the spec wrote it), -qpl lists the files with their paths, -qplv lists them in ls -l style, -qpR shows what it needs:

$ cd ~/rpmbuild/RPMS/noarch
$ rpm -qpi cmd-hangman-1.0.0-1.el9.noarch.rpm
Name        : cmd-hangman
Version     : 1.0.0
Release     : 1.el9
Architecture: noarch
Install Date: (not installed)
Group       : Unspecified
Size        : 2181
License     : CC0-1.0
Signature   : (none)
Source RPM  : cmd-hangman-1.0.0-1.el9.src.rpm
Build Date  : Fri Oct 30 23:59:00 2026
Build Host  : df92683b48c6
URL         : https://techcommune.org
Summary     : Linux command hangman for the terminal
Description :
Guess the Linux command one letter at a time before the
stick figure is finished. A packaging tutorial game.
$ rpm -qpl cmd-hangman-1.0.0-1.el9.noarch.rpm
/usr/bin/cmd-hangman
$ rpm -qplv cmd-hangman-1.0.0-1.el9.noarch.rpm
-rwxr-xr-x    1 root     root                     2181 Oct 30 23:59 /usr/bin/cmd-hangman
$ rpm -qpR cmd-hangman-1.0.0-1.el9.noarch.rpm
/usr/bin/python3
python3
rpmlib(CompressedFileNames) <= 3.0.4-1
rpmlib(FileDigests) <= 4.6.0-1
rpmlib(PayloadFilesHavePrefix) <= 4.0-1
rpmlib(PayloadIsZstd) <= 5.4.18-1

The requirement list shows more than the one line in the spec: rpmbuild added by itself a requirement on the program that the #! line names, plus the rpm tools needed to install it. Two more views, the changelog and the raw content (an rpm is a header plus a compressed cpio archive of the files):

$ rpm -qp --changelog cmd-hangman-1.0.0-1.el9.noarch.rpm
* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-1
- First package
$ rpm2cpio cmd-hangman-1.0.0-1.el9.noarch.rpm | cpio -tv 2>&1
5 blocks
-rwxr-xr-x   1 root     root         2181 Oct 30 23:59 ./usr/bin/cmd-hangman

One thing to look at: the first line of the packaged program. RPM rewrites it during the build:

$ rpm2cpio cmd-hangman-1.0.0-1.el9.noarch.rpm | cpio -i --to-stdout ./usr/bin/cmd-hangman 2>/dev/null | head -1
#!/usr/bin/python3

On RHEL-family systems, "#!/usr/bin/env python3" is replaced with a fixed path ("#!/usr/bin/python3") during the build, by a step called brp-mangle-shebangs. The reason is that a package should run the interpreter that it depends on, not whichever python3 the user happens to have first in the PATH. It also makes rpm see that the package needs /usr/bin/python3.

Check it with rpmlint

rpmlint is the RPM checker; it knows the packaging guidelines of Fedora and Red Hat. Run it on the spec and on the package:

$ rpmlint ~/rpmbuild/SPECS/cmd-hangman.spec ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-1.el9.noarch.rpm
/home/tc/rpmbuild/SPECS/cmd-hangman.spec: W: invalid-url Source0: cmd-hangman-1.0.0.tar.gz
cmd-hangman.noarch: W: invalid-license CC0-1.0
cmd-hangman.noarch: W: no-documentation
cmd-hangman.noarch: W: no-manual-page-for-binary cmd-hangman
1 packages and 1 specfiles checked; 0 errors, 4 warnings.

Each line is the package, a letter for the severity (E error, W warning), the tag and the details. The findings here are not errors, but a package for others should not have them:

invalid-url Source0           the name of a tarball is not a web address. Real projects put the download URL there; rpmbuild still takes the file from SOURCES by its last part. Harmless for a package you build yourself
invalid-license CC0-1.0       rpmlint 1.11 on Rocky 9 does not know SPDX names. Its list says CC0
no-documentation              a package should ship its README
no-manual-page-for-binary     every command in /usr/bin should have a man page

Fix the licence name in the spec (the next section). Add the missing files to the source: a licence text, a README and a man page. They go into the source folder, and the tarball is made again:

$ cat cmd-hangman-1.0.0/LICENSE
CC0 1.0 Universal - Public Domain Dedication

The person who associated this work with this deed has dedicated the work
to the public domain by waiving all of his or her rights to the work
worldwide under copyright law, including all related and neighbouring
rights, to the extent allowed by law. You can copy, modify, distribute and
perform the work, even for commercial purposes, all without asking
permission. Full text: https://creativecommons.org/publicdomain/zero/1.0/
$ cat cmd-hangman-1.0.0/README.md
# cmd-hangman

Guess the Linux command, one letter at a time, before the stick figure is
finished. A tutorial game from https://techcommune.org

Run: cmd-hangman
$ cat cmd-hangman-1.0.0/cmd-hangman.1
.TH CMD-HANGMAN 1 "October 2026" "cmd-hangman 1.0.0" "TechCommune"
.SH NAME
cmd-hangman \- guess the Linux command before the stick figure is finished
.SH SYNOPSIS
.B cmd-hangman
.SH DESCRIPTION
.B cmd-hangman
picks a Linux command at random, shows a hint and one underscore per letter.
Type one letter at a time. Six wrong letters finish the stick figure and end
the round. At the end of a round the game asks whether to play again.
.SH AUTHOR
TechCommune, https://techcommune.org
$ cp cmd-hangman cmd-hangman-1.0.0/
$ tar -czf ~/rpmbuild/SOURCES/cmd-hangman-1.0.0.tar.gz cmd-hangman-1.0.0
$ tar -tzf ~/rpmbuild/SOURCES/cmd-hangman-1.0.0.tar.gz
cmd-hangman-1.0.0/
cmd-hangman-1.0.0/LICENSE
cmd-hangman-1.0.0/README.md
cmd-hangman-1.0.0/cmd-hangman
cmd-hangman-1.0.0/cmd-hangman.1

Update the spec and rebuild

Four changes to the spec file. The licence name becomes CC0. The %install section installs the manual page into man1 (-D creates the folder, -m 0644 makes it an ordinary readable file). %files lists the new files: %license and %doc mark the licence and the README, which rpm places in /usr/share/licenses and /usr/share/doc, and the man page is listed with a star at the end of its name because rpm compresses man pages, and the name ends .gz (or .bz2) depending on the system. The Release is raised to 2, because the contents of this version changed. The changelog gets a second entry:

$ cat ~/rpmbuild/SPECS/cmd-hangman.spec
Name:       cmd-hangman
Version:    1.0.0
Release:    2%{?dist}
Summary:    Linux command hangman for the terminal
License:    CC0
URL:        https://techcommune.org
Source0:    %{name}-%{version}.tar.gz
BuildArch:  noarch
Requires:   python3

%description
Guess the Linux command one letter at a time before the
stick figure is finished. A packaging tutorial game.

%prep
%setup -q

%build
# nothing to compile

%install
install -D -m 0755 cmd-hangman %{buildroot}%{_bindir}/cmd-hangman
install -D -m 0644 cmd-hangman.1 %{buildroot}%{_mandir}/man1/cmd-hangman.1

%files
%license LICENSE
%doc README.md
%{_bindir}/cmd-hangman
%{_mandir}/man1/cmd-hangman.1*

%changelog
* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-2
- Add the licence, a README and a manual page

* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-1
- First package
$ rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | grep -E "^(Wrote|error|warning)"
Wrote: /home/tc/rpmbuild/SRPMS/cmd-hangman-1.0.0-2.el9.src.rpm
Wrote: /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-2.el9.noarch.rpm
$ rpmlint ~/rpmbuild/SPECS/cmd-hangman.spec ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-2.el9.noarch.rpm
/home/tc/rpmbuild/SPECS/cmd-hangman.spec: W: invalid-url Source0: cmd-hangman-1.0.0.tar.gz
1 packages and 1 specfiles checked; 0 errors, 1 warnings.
$ rpm -qplv ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-2.el9.noarch.rpm
-rwxr-xr-x    1 root     root                     2181 Oct 30 23:59 /usr/bin/cmd-hangman
drwxr-xr-x    2 root     root                        0 Oct 30 23:59 /usr/share/doc/cmd-hangman
-rw-r--r--    1 root     root                      162 Oct 30 23:59 /usr/share/doc/cmd-hangman/README.md
drwxr-xr-x    2 root     root                        0 Oct 30 23:59 /usr/share/licenses/cmd-hangman
-rw-r--r--    1 root     root                      475 Oct 30 23:59 /usr/share/licenses/cmd-hangman/LICENSE
-rw-r--r--    1 root     root                      312 Oct 30 23:59 /usr/share/man/man1/cmd-hangman.1.gz

0 errors is the goal. The one warning left is the Source0 address, which is explained above and is fine for a package you build yourself.

Install, run, remove

Install the local file with dnf. Giving a path to a file is enough; dnf also fetches what the package needs. (rpm -i installs without resolving, the same difference as dpkg -i and apt.)

$ cd ~
$ sudo dnf -y install /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.0-2.el9.noarch.rpm 2>&1 | tail -8
  Installing       : cmd-hangman-1.0.0-2.el9.noarch                         1/1
  Running scriptlet: cmd-hangman-1.0.0-2.el9.noarch                         1/1
  Verifying        : cmd-hangman-1.0.0-2.el9.noarch                         1/1

Installed:
  cmd-hangman-1.0.0-2.el9.noarch

Complete!
$ command -v cmd-hangman
/usr/bin/cmd-hangman

Play one round (the word is fixed in these examples so the page is repeatable; yours is random):

$ cmd-hangman
=== cmd-hangman by TechCommune ===





_______

Command: _ _ _ _
Hint:    search text for a pattern
Guess a letter: g





_______

Command: g _ _ _
Hint:    search text for a pattern
Guess a letter: r





_______

Command: g r _ _
Hint:    search text for a pattern
Guess a letter: e





_______

Command: g r e _
Hint:    search text for a pattern
Guess a letter: p





_______

Command: g r e p
Hint:    search text for a pattern

You got it! The command was: grep

Play again? (y/n) n
Happy hacking!

Ask rpm what it knows. -q is the query, -i information, -l files, -f which package owns a file, -V verifies the installed files against the package:

$ rpm -q cmd-hangman
cmd-hangman-1.0.0-2.el9.noarch
$ rpm -ql cmd-hangman
/usr/bin/cmd-hangman
/usr/share/doc/cmd-hangman
/usr/share/doc/cmd-hangman/README.md
/usr/share/licenses/cmd-hangman
/usr/share/licenses/cmd-hangman/LICENSE
/usr/share/man/man1/cmd-hangman.1.gz
$ rpm -qf /usr/bin/cmd-hangman
cmd-hangman-1.0.0-2.el9.noarch
$ rpm -qi cmd-hangman | head -12
Name        : cmd-hangman
Version     : 1.0.0
Release     : 2.el9
Architecture: noarch
Install Date: Fri Oct 30 23:59:06 2026
Group       : Unspecified
Size        : 3130
License     : CC0
Signature   : (none)
Source RPM  : cmd-hangman-1.0.0-2.el9.src.rpm
Build Date  : Fri Oct 30 23:59:03 2026
Build Host  : df92683b48c6
$ head -1 /usr/bin/cmd-hangman
#!/usr/bin/python3
$ rpm -V cmd-hangman && echo "files unchanged"
files unchanged

(rpm -V prints nothing when everything matches. Edit /usr/bin/cmd-hangman and run it again, and it shows what changed.) The manual page is installed with the package; on this stripped-down container the man program may be missing, which does not affect the package:

$ ls /usr/share/man/man1/cmd-hangman.1* /usr/share/licenses/cmd-hangman /usr/share/doc/cmd-hangman
/usr/share/man/man1/cmd-hangman.1.gz

/usr/share/doc/cmd-hangman:
README.md

/usr/share/licenses/cmd-hangman:
LICENSE

Remove it:

$ sudo dnf -y remove cmd-hangman 2>&1 | tail -3
  cmd-hangman-1.0.0-2.el9.noarch

Complete!
$ command -v cmd-hangman
(exit status 1)
$ rpm -q cmd-hangman
package cmd-hangman is not installed
(exit status 1)

Test on a clean machine

As with a .deb, the test that counts is on a fresh system. This section uses a second, clean Rocky Linux 9 machine with nothing of yours on it. Copy the package there (scp in real life; here the file is placed straight into the machine) and install it:

$ cat /etc/rocky-release
Rocky Linux release 9.8 (Blue Onyx)
$ command -v cmd-hangman
(exit status 1)
$ ls
cmd-hangman-1.0.0-2.el9.noarch.rpm
$ sudo dnf -y install ./cmd-hangman-1.0.0-2.el9.noarch.rpm 2>&1 | tail -6
  Verifying        : cmd-hangman-1.0.0-2.el9.noarch                         1/1

Installed:
  cmd-hangman-1.0.0-2.el9.noarch

Complete!

(A path that starts with ./ is how dnf is told to use a local file.) One difference from the Ubuntu guide: Python 3 is already on every Rocky 9 machine, because dnf itself is written in it, so the Requires: python3 line is satisfied from the start and there is nothing to fetch. The troubleshooting section shows what dnf does for a requirement that is missing. Run the game:

$ command -v cmd-hangman
/usr/bin/cmd-hangman
$ cmd-hangman
=== cmd-hangman by TechCommune ===





_______

Command: _ _ _ _
Hint:    search text for a pattern
Guess a letter: g





_______

Command: g _ _ _
Hint:    search text for a pattern
Guess a letter: r





_______

Command: g r _ _
Hint:    search text for a pattern
Guess a letter: e





_______

Command: g r e _
Hint:    search text for a pattern
Guess a letter: p





_______

Command: g r e p
Hint:    search text for a pattern

You got it! The command was: grep

Play again? (y/n) n
Happy hacking!
$ rpm -q --requires cmd-hangman
/usr/bin/python3
python3
rpmlib(CompressedFileNames) <= 3.0.4-1
rpmlib(FileDigests) <= 4.6.0-1
rpmlib(PayloadFilesHavePrefix) <= 4.0-1
rpmlib(PayloadIsZstd) <= 5.4.18-1
$ rpm -qa --last | head -3
cmd-hangman-1.0.0-2.el9.noarch                Fri Oct 30 23:59:10 2026
sudo-1.9.17p2-3.el9_8.3.x86_64                Fri Oct 30 23:58:55 2026
yum-4.14.0-34.el9_8.rocky.0.1.noarch          Fri Oct 30 23:58:50 2026

Always do this test. A package that "works" on the build machine can depend on something that the build machine happens to have.

A new version: upgrade

There are two numbers to know. Version is the program's version. Release is the number of the package of that version, and goes back to 1 when the Version rises. A fix in the packaging, with the same program, raises only the Release (as above, 1 to 2). A new program raises the Version. Add a command to the game, make version 1.0.1 and build it. Back on the build machine:

$ sed -i 's/    "df": "show free disk space",/    "df": "show free disk space",\n    "tar": "bundle files into an archive",/' cmd-hangman
$ grep -n -B1 -A1 '"tar"' cmd-hangman
20-    "df": "show free disk space",
21:    "tar": "bundle files into an archive",
22-}
$ mv cmd-hangman-1.0.0 cmd-hangman-1.0.1
$ cp cmd-hangman cmd-hangman-1.0.1/
$ sed -i 's/cmd-hangman 1.0.0/cmd-hangman 1.0.1/' cmd-hangman-1.0.1/cmd-hangman.1
$ tar -czf ~/rpmbuild/SOURCES/cmd-hangman-1.0.1.tar.gz cmd-hangman-1.0.1
$ sed -i 's/^Version:.*/Version:    1.0.1/; s/^Release:.*/Release:    1%{?dist}/' ~/rpmbuild/SPECS/cmd-hangman.spec
$ sed -i '/^%changelog/a * Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.1-1\n- Add the tar command\n' ~/rpmbuild/SPECS/cmd-hangman.spec
$ sed -n '/^%changelog/,$p' ~/rpmbuild/SPECS/cmd-hangman.spec
%changelog
* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.1-1
- Add the tar command

* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-2
- Add the licence, a README and a manual page

* Fri Oct 30 2026 TechCommune <admin@techcommune.org> - 1.0.0-1
- First package
$ rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | grep -E "^(Wrote|error)"
Wrote: /home/tc/rpmbuild/SRPMS/cmd-hangman-1.0.1-1.el9.src.rpm
Wrote: /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
$ rpmlint ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
1 packages and 0 specfiles checked; 0 errors, 0 warnings.

(rpmlint is given only the package this time, so the Source0 warning, which comes from the spec file, does not appear.) rpm sorts versions, so a bigger Version wins, and for the same Version the bigger Release wins. Test it on the clean machine; dnf sees that an older version is installed and upgrades it in place:

$ rpm -q cmd-hangman
cmd-hangman-1.0.0-2.el9.noarch
$ sudo dnf -y upgrade ./cmd-hangman-1.0.1-1.el9.noarch.rpm 2>&1 | tail -6
  Verifying        : cmd-hangman-1.0.0-2.el9.noarch                         2/2

Upgraded:
  cmd-hangman-1.0.1-1.el9.noarch

Complete!
$ rpm -q cmd-hangman
cmd-hangman-1.0.1-1.el9.noarch
$ grep -c '"tar"' /usr/bin/cmd-hangman
1
$ sudo dnf -y downgrade ./cmd-hangman-1.0.0-2.el9.noarch.rpm 2>&1 | tail -3
  cmd-hangman-1.0.0-2.el9.noarch

Complete!
$ rpm -q cmd-hangman
cmd-hangman-1.0.0-2.el9.noarch

dnf downgrade goes back when a release turns out to be bad.

Sign the package

A signature tells the person installing it that the package comes from you and was not changed on the way. Repositories use it: dnf checks every package against a key it was given. Make a key (here without a passphrase, only for the example; a real signing key has one and lives on a protected machine):

$ gpg --batch --passphrase '' --quick-generate-key "TechCommune Packages <admin@techcommune.org>" rsa3072 sign never 2>&1 | tail -3
gpg: key 184D7BD92CF9C4E7 marked as ultimately trusted
gpg: directory '/home/tc/.gnupg/openpgp-revocs.d' created
gpg: revocation certificate stored as '/home/tc/.gnupg/openpgp-revocs.d/17AB4917F3B87AF023FC418B184D7BD92CF9C4E7.rev'
$ gpg --list-keys --with-colons | grep -E "^(pub|fpr|uid)" | head -3
gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
pub:u:3072:1:184D7BD92CF9C4E7:1793404756:::u:::scSC::::::23::0:
fpr:::::::::17AB4917F3B87AF023FC418B184D7BD92CF9C4E7:
uid:u::::1793404756::31536EE0DD6E9AB49C2589F12CE533520FB4EC9F::TechCommune Packages <admin@techcommune.org>::::::::::0:

Tell rpm which key signs, then sign. The macro goes into ~/.rpmmacros; %_gpg_name is the key's name:

$ cat ~/.rpmmacros
%_gpg_name TechCommune Packages
$ rpmsign --addsign ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
warning: Could not set GPG_TTY to stdin: Inappropriate ioctl for device
/home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm:
$ rpm -qp --qf '%{NAME}-%{VERSION}-%{RELEASE}  signature: %{RSAHEADER:pgpsig}\n' ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
warning: /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm: Header V4 RSA/SHA256 Signature, key ID 2cf9c4e7: NOKEY
cmd-hangman-1.0.1-1.el9  signature: RSA/SHA256, Fri Oct 30 23:59:17 2026, Key ID 184d7bd92cf9c4e7

Checking a signature needs the public key to be known to rpm. Without it, the check says it cannot be verified; once the key is imported, it passes:

$ rpm -K ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
/home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm: digests SIGNATURES NOT OK
(exit status 1)
$ gpg --export --armor "TechCommune Packages" > ~/RPM-GPG-KEY-techcommune
$ sudo rpm --import /home/tc/RPM-GPG-KEY-techcommune
$ rpm -K ~/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
/home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm: digests signatures OK
$ rpm -q gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE}  %{SUMMARY}\n'
gpg-pubkey-2cf9c4e7-6ae52f54  TechCommune Packages <admin@techcommune.org> public key

Publish the public key file (RPM-GPG-KEY-techcommune) next to the repository; users import it once.

A tiny repository of your own

With a repository dnf installs the package by name and offers every new version in dnf upgrade. A repository is a folder of .rpm files plus an index made by createrepo_c, and a .repo file that tells dnf where it is. Build it on the clean machine. The tools to build the index are installed there only because the machine acts as the repository server:

$ sudo dnf -y install createrepo_c 2>&1 | tail -2

Complete!
$ sudo mkdir -p /srv/repo
$ sudo chown tc: /srv/repo
$ createrepo_c /srv/repo
Directory walk started
Directory walk done - 1 packages
Temporary output repo path: /srv/repo/.repodata/
Preparing sqlite DBs
Pool started (with 5 workers)
Pool finished
$ ls /srv/repo /srv/repo/repodata
/srv/repo:
RPM-GPG-KEY-techcommune
cmd-hangman-1.0.1-1.el9.noarch.rpm
repodata

/srv/repo/repodata:
97a8c90724a9c8d246f62afa998ebd50525a3a382f5692f499cff728831f8cba-primary.xml.gz
a40c3d3f34da93ad5ea98e0bbaf025ab8520a3e01f0c11c5597d6a80a4edef3e-other.sqlite.bz2
abf007371b6193d2c7fcc3cda10e6395b9b73a931e85daebe3a6de97d6c0bcdf-filelists.xml.gz
d6da778d86f61e95767a2582aa47c27e3acd732c63c4d82b517d625284617736-other.xml.gz
f4f3b590bd0852a9fe650ae8fe9c11be1e3956d44ea261837de8c3c7ea88c913-primary.sqlite.bz2
fde0fabe55df5affe963a7f3d816221f1136912c9c600c5f17205f0739c3cf8c-filelists.sqlite.bz2
repomd.xml

Describe the repository to dnf. gpgcheck=1 makes dnf refuse any package that is not signed by a key it trusts, and gpgkey= says where the key is (dnf imports it the first time, after asking):

$ cat /tmp/tc-local.repo
[tc-local]
name=TechCommune local repository
baseurl=file:///srv/repo
enabled=1
gpgcheck=1
gpgkey=file:///srv/repo/RPM-GPG-KEY-techcommune
$ sudo cp /tmp/tc-local.repo /etc/yum.repos.d/tc-local.repo
$ sudo dnf -y remove cmd-hangman 2>&1 | tail -2

Complete!
$ sudo dnf --disablerepo='*' --enablerepo=tc-local list available
TechCommune local repository                    326 kB/s | 1.0 kB     00:00
Available Packages
cmd-hangman.noarch                     1.0.1-1.el9                      tc-local
$ sudo dnf -y --disablerepo='*' --enablerepo=tc-local install cmd-hangman 2>&1 | tail -14
Key imported successfully
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Running transaction
  Preparing        :                                                        1/1
  Installing       : cmd-hangman-1.0.1-1.el9.noarch                         1/1
  Verifying        : cmd-hangman-1.0.1-1.el9.noarch                         1/1

Installed:
  cmd-hangman-1.0.1-1.el9.noarch

Complete!
$ rpm -q cmd-hangman
cmd-hangman-1.0.1-1.el9.noarch

(-y also accepts the key import, which dnf asks about the first time; in a real installation read the fingerprint it shows and compare it with the one you publish.) To serve the folder to other machines, put it behind any web server and use an http:// or https:// baseurl. After adding a new package, run createrepo_c --update on the folder to refresh the index.

When the build or install goes wrong

The errors that happen, each produced here on purpose. Back on the build machine:


The tarball is missing, or is called something other than Source0 says:

$ rpmbuild -bb ~/rpmbuild/SPECS/bad.spec 2>&1 | tail -4
error: Bad source: /home/tc/rpmbuild/SOURCES/cmd-hangman-1.0.1-wrong.tar.gz: No such file or directory

The folder inside the tarball does not carry the name-version name that %setup expects:

$ rpmbuild -bb ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | grep -v '^+' | tail -6
/var/tmp/rpm-tmp.htasig: line 38: cd: cmd-hangman-1.0.1: No such file or directory
error: Bad exit status from /var/tmp/rpm-tmp.htasig (%prep)


RPM build errors:
    Bad exit status from /var/tmp/rpm-tmp.htasig (%prep)

A file that %files lists but %install never created:

$ rpmbuild -bb ~/rpmbuild/SPECS/bad.spec 2>&1 | tail -4


RPM build errors:
    File not found: /home/tc/rpmbuild/BUILDROOT/cmd-hangman-1.0.1-1.el9.x86_64/usr/bin/cmd-hangman-extra

The opposite: a file that %install created but %files does not list:

$ rpmbuild -bb ~/rpmbuild/SPECS/bad.spec 2>&1 | tail -6
   /usr/share/man/man1/cmd-hangman.1.gz


RPM build errors:
    Installed (but unpackaged) file(s) found:
   /usr/share/man/man1/cmd-hangman.1.gz

Installing with rpm when a dependency is missing. rpm -i does not fetch anything; it stops and says what is needed. Here a test build of the package asks for one more package, tree, that the clean machine does not have:

$ grep ^Requires ~/rpmbuild/SPECS/needs.spec
Requires:   python3 tree
$ command -v tree
(exit status 1)
$ sudo rpm -i cmd-hangman-1.0.1-9.el9.noarch.rpm
error: Failed dependencies:
	tree is needed by cmd-hangman-1.0.1-9.el9.noarch
(exit status 1)
$ sudo dnf -y install ./cmd-hangman-1.0.1-9.el9.noarch.rpm 2>&1 | grep -E "^ (tree|cmd-hangman)|Installing dependencies|Installed|Complete"
 cmd-hangman        noarch        1.0.1-9.el9         @commandline        9.4 k
Installing dependencies:
 tree               x86_64        1.8.0-10.el9        baseos               55 k
Installed size: 116 k
Installed:
Complete!
$ command -v tree cmd-hangman
/usr/bin/tree
/usr/bin/cmd-hangman

The rule that follows: install .rpm files with dnf install ./file.rpm, not rpm -i; dnf resolves what is missing.

Keep the build in a script

Every release is the same few steps, so a script is better than your memory. It makes the tarball from the source folder named after the version and builds the package:

$ cat build-rpm.sh
#!/bin/sh
# build-rpm.sh VERSION - build cmd-hangman-VERSION from the folder cmd-hangman-VERSION
set -e
V=${1:?usage: build-rpm.sh VERSION}
N=cmd-hangman-$V
[ -d "$N" ] || { echo "no source folder $N"; exit 1; }
rpmdev-setuptree
tar -czf ~/rpmbuild/SOURCES/$N.tar.gz "$N"
cp cmd-hangman.spec ~/rpmbuild/SPECS/
rpmbuild -ba ~/rpmbuild/SPECS/cmd-hangman.spec 2>&1 | grep -E "^(Wrote|error)"
rpmlint ~/rpmbuild/RPMS/noarch/$N-*.rpm
$ cp ~/rpmbuild/SPECS/cmd-hangman.spec cmd-hangman.spec
$ chmod +x build-rpm.sh
$ ./build-rpm.sh 1.0.1
Wrote: /home/tc/rpmbuild/SRPMS/cmd-hangman-1.0.1-1.el9.src.rpm
Wrote: /home/tc/rpmbuild/RPMS/noarch/cmd-hangman-1.0.1-1.el9.noarch.rpm
cmd-hangman.noarch: W: incoherent-version-in-changelog 1.0.1-1 ['1.0.1-9.el9', '1.0.1-9']
2 packages and 0 specfiles checked; 0 errors, 1 warnings.

Keep the spec file and the source folder together in a git repository, and every release is a commit and one command.

Side by side: .deb and .rpm

The same game, packaged for both families. The ideas are the same; the names differ:

                          .deb (Ubuntu, Debian)                .rpm (Rocky, RHEL, Fedora)
build from                a folder laid out like the target    a spec file plus a source tarball
identity file             DEBIAN/control                       the header of the .spec file
build command             dpkg-deb --build DIR                 rpmbuild -ba FILE.spec
install into the package  you copy files into the folder      %install copies into %{buildroot}
dependencies              Depends: python3                     Requires: python3
history                   changelog.gz (strict format)         %changelog in the spec
version fields            Version: 1.0.1 (revision with -1)    Version + Release (1.0.1-1.el9)
architecture-neutral      Architecture: all                    BuildArch: noarch
checker                   lintian                              rpmlint
install a local file      apt install ./x.deb                  dnf install ./x.rpm
low-level install         dpkg -i                              rpm -i
list files / owner        dpkg -L / dpkg -S                    rpm -ql / rpm -qf
verify installed files    debsums -c                           rpm -V
repository index          dpkg-scanpackages / apt-ftparchive   createrepo_c
signature                 repository Release file (GPG)        each package and the repo (rpmsign)

Next, the whole series: Python Game: Build Linux Command Hangman, Build a .deb package, Build an .rpm package. To go further: build a package for a program that must be compiled (then the architecture is amd64 or x86_64, not all or noarch), or look at fpm, a tool that makes both from one description.