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.
▶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.