Build a YUM Repository

Create a managed RPM repository with per-architecture views, noise filtering, and a working dnf client configuration.

This tutorial builds a production-shaped RPM repository from scratch. By the end you will have a workspace holding a pigsty repository with an el9 Dist, two architecture views rendered from one package pool, debuginfo noise filtered out, only the newest version of each package kept, and a .repo file that dnf accepts.

Plan for about fifteen minutes. Every command runs locally; nothing talks to the network.

Before you start

You need three things.

SOW on your PATH. Check it:

sow version
sow 0.2.0-dev darwin/arm64 go1.26.5

If that fails, see Installation.

Some RPM files. Anything works — packages you built, packages you downloaded, or a directory you already ship. This tutorial uses nine RPMs spanning x86_64, aarch64, and noarch, including two versions of etcd and a pair of debuginfo packages, because those are the cases that make the interesting behaviour visible.

A directory you can write to. The workspace holds everything; nothing is written outside it.

Step 1: Create the workspace

A workspace is one directory containing sow.yml plus the state directory .sow/. Everything else lives under it.

mkdir -p ~/repo && cd ~/repo
sow init .
initialized /home/you/repo: config_created=true repositories_initialized=0 dists_initialized=0

sow init wrote a minimal sow.yml:

schema: sow/v2
architectures:
  - x86_64
  - aarch64

Those two architectures are the permitted set for the whole workspace. A package whose native architecture is not listed is rejected rather than silently widening your configuration.

sow init is idempotent. Running it again on the same directory converges rather than failing, so it is safe in provisioning scripts.

Step 2: Create the repository

A Repository owns a package pool, a set of Dists, and its own SQLite state. It is also the boundary for locking, transactions, and generations.

sow repo new pigsty
created pigsty: path=/home/you/repo/pigsty protected=false dists=0 generation=0 status=clean packages=0 memberships=0

You now have ~/repo/pigsty/, which is the directory you will eventually serve over HTTP. The internal state lives in ~/repo/.sow/, one level up — it never ends up inside anything you publish.

Step 3: Create the RPM Dist

A Dist is a named, single-format set of packages. Create one for Enterprise Linux 9:

sow dist new el9 --format rpm -r pigsty
created el9: format=rpm architectures=x86_64,aarch64 members=0/0 generation=1 dirty=false

The Dist inherited both workspace architectures. Look at what it produced:

find pigsty -type f | sort
pigsty/dists/el9/aarch64/repodata/0df96f0b046b6c098398194f908cc99d90bf3af8c5f66d262b2e6d43a658a58f-primary.xml.gz
pigsty/dists/el9/aarch64/repodata/8402c28c7c848d02a6ef5c728a8741a2d402792bf9dc4a62ec0657912f4c1719-filelists.xml.gz
pigsty/dists/el9/aarch64/repodata/c16c7739903ecd19f56b49c14f11710643f6de391d13646c22ce95c6910d6106-other.xml.gz
pigsty/dists/el9/aarch64/repodata/repomd.xml
pigsty/dists/el9/x86_64/repodata/0df96f0b046b6c098398194f908cc99d90bf3af8c5f66d262b2e6d43a658a58f-primary.xml.gz
pigsty/dists/el9/x86_64/repodata/8402c28c7c848d02a6ef5c728a8741a2d402792bf9dc4a62ec0657912f4c1719-filelists.xml.gz
pigsty/dists/el9/x86_64/repodata/c16c7739903ecd19f56b49c14f11710643f6de391d13646c22ce95c6910d6106-other.xml.gz
pigsty/dists/el9/x86_64/repodata/repomd.xml

An empty Dist is already a valid repository. Each architecture view has complete repodata that dnf makecache will accept — clients do not have to wait for your first package. Metadata files are named by their own SHA-256, and repomd.xml is the only pointer.

Step 4: Add packages

Point sow add at a file, several files, or a directory. Directories are scanned at the top level only; pass -R to recurse.

sow add ~/packages/rpm -d el9
add repository=pigsty operation=4475889911918567992 accepted=9 failed=0 memberships=+9/-0 revision=2 generation=2 dirty=false
item input="/home/you/packages/rpm/armadillo-10.8.2-3.el9.x86_64.rpm" status=accepted format=rpm coordinate="armadillo-0:10.8.2-3.el9.x86_64" sha256:7ce1effe2897a6cd1a31849bdb2e315b53186a1bb09ed71d8d489190795cded2 dists=el9:accepted
item input="/home/you/packages/rpm/blackbox_exporter-0.28.0-1.aarch64.rpm" status=accepted format=rpm coordinate="blackbox_exporter-0:0.28.0-1.aarch64" sha256:ceb1b8660f8bc1fe59fb7a28e750e19a1ccd010a254a50e82328adb5818a5943 dists=el9:accepted
item input="/home/you/packages/rpm/etcd-3.5.12-1.el8.x86_64.rpm" status=accepted format=rpm coordinate="etcd-0:3.5.12-1.el8.x86_64" sha256:bc795bdd732112c36eecffa1e6f94f6c093f5deca56d71d0026ec61e89893f91 dists=el9:accepted
item input="/home/you/packages/rpm/etcd-3.5.30-1.el9.x86_64.rpm" status=accepted format=rpm coordinate="etcd-0:3.5.30-1.el9.x86_64" sha256:a905f9918f4ad224b3eb7fd6bafed50a578e3d321a2900fc38a642af6f342e0a dists=el9:accepted
item input="/home/you/packages/rpm/etcd-debuginfo-3.3.11-4.el8.x86_64.rpm" status=accepted format=rpm coordinate="etcd-debuginfo-0:3.3.11-4.el8.x86_64" sha256:3dcee7ab93e67cf5ec4cd6a2dff2c1e4f8d189cc1751654d2fb75503cee96475 dists=el9:accepted
item input="/home/you/packages/rpm/etcd-debugsource-3.3.11-4.el8.x86_64.rpm" status=accepted format=rpm coordinate="etcd-debugsource-0:3.3.11-4.el8.x86_64" sha256:f6020dbfd40c3d68c3dc1adefbfd39f304944c7c8268c4e206139ca589d02110 dists=el9:accepted
item input="/home/you/packages/rpm/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm" status=accepted format=rpm coordinate="patroni-0:4.1.4-1PGDG.rhel9.6.noarch" sha256:077938eac0fae939368887e4f20e55e2af7dfb9f0e885869df8841213bd97fd6 dists=el9:accepted
item input="/home/you/packages/rpm/pev2-1.22.0-1.noarch.rpm" status=accepted format=rpm coordinate="pev2-0:1.22.0-1.noarch" sha256:a8456bb578f82d28b1beebc4d756bad4a508a3e4944ef57dc7e2fd048882423b dists=el9:accepted
item input="/home/you/packages/rpm/pgbouncer-1.25.2-42PGDG.rhel9.6.aarch64.rpm" status=accepted format=rpm coordinate="pgbouncer-0:1.25.2-42PGDG.rhel9.6.aarch64" sha256:5d0e1b7a72c72b37fab5047a85e4dddecd17d41e376b96300706b68f1b3d3607 dists=el9:accepted

Several things happened in that one command, all inside a single transaction.

The format and architecture came from the RPM header, not the filename. A binary RPM renamed to *.src.rpm is still indexed by its real architecture; a package whose header says src is rejected outright.

Each package got a coordinate — its NEVRA, like etcd-0:3.5.30-1.el9.x86_64 — and a SHA-256 that identifies the exact bytes. Both are printed so you can copy them into later commands without hand-assembling anything.

add built the public tree before returning. dirty=false and generation=2 mean the repository on disk is complete right now. That is the default; you can defer the build with --skip, which we use in Step 7.

Confirm:

sow status
repository=pigsty status=clean ready_to_copy=true revision=2 generation=2 dirty_dists= pending=0/0 locked=false

ready_to_copy=true is the field to script against. It means the pool/ and dists/ directories are a coherent set you can rsync as-is.

Step 5: Read the layout

Two structures came out of that add. Look at the pool first:

sow ls -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
sha256:7ce1effe2897a6cd1a31849bdb2e315b53186a1bb09ed71d8d489190795cded2	rpm:armadillo-0:10.8.2-3.el9.x86_64	el9	el9	pool/a/armadillo/armadillo-10.8.2-3.el9.x86_64.rpm
sha256:ceb1b8660f8bc1fe59fb7a28e750e19a1ccd010a254a50e82328adb5818a5943	rpm:blackbox_exporter-0:0.28.0-1.aarch64	el9	el9	pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
sha256:bc795bdd732112c36eecffa1e6f94f6c093f5deca56d71d0026ec61e89893f91	rpm:etcd-0:3.5.12-1.el8.x86_64	el9	el9	pool/e/etcd/etcd-3.5.12-1.el8.x86_64.rpm
sha256:a905f9918f4ad224b3eb7fd6bafed50a578e3d321a2900fc38a642af6f342e0a	rpm:etcd-0:3.5.30-1.el9.x86_64	el9	el9	pool/e/etcd/etcd-3.5.30-1.el9.x86_64.rpm
sha256:3dcee7ab93e67cf5ec4cd6a2dff2c1e4f8d189cc1751654d2fb75503cee96475	rpm:etcd-debuginfo-0:3.3.11-4.el8.x86_64	el9	el9	pool/e/etcd/etcd-debuginfo-3.3.11-4.el8.x86_64.rpm
sha256:f6020dbfd40c3d68c3dc1adefbfd39f304944c7c8268c4e206139ca589d02110	rpm:etcd-debugsource-0:3.3.11-4.el8.x86_64	el9	el9	pool/e/etcd/etcd-debugsource-3.3.11-4.el8.x86_64.rpm
sha256:077938eac0fae939368887e4f20e55e2af7dfb9f0e885869df8841213bd97fd6	rpm:patroni-0:4.1.4-1PGDG.rhel9.6.noarch	el9	el9	pool/p/patroni/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm
sha256:a8456bb578f82d28b1beebc4d756bad4a508a3e4944ef57dc7e2fd048882423b	rpm:pev2-0:1.22.0-1.noarch	el9	el9	pool/p/pev2/pev2-1.22.0-1.noarch.rpm
sha256:5d0e1b7a72c72b37fab5047a85e4dddecd17d41e376b96300706b68f1b3d3607	rpm:pgbouncer-0:1.25.2-42PGDG.rhel9.6.aarch64	el9	el9	pool/p/pgbouncer/pgbouncer-1.25.2-42PGDG.rhel9.6.aarch64.rpm

The pool path is pool/<prefix>/<source>/<filename>. For RPMs the source comes from the SOURCERPM header, so etcd-debuginfo and etcd-debugsource sit next to etcd in pool/e/etcd/. The pool is flat with respect to Dists: one object, one location, however many Dists reference it.

Architecture views

The pool is not what dnf reads. Each Dist renders one view per architecture:

find pigsty/dists/el9 -name "*.rpm" | sort
pigsty/dists/el9/aarch64/pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
pigsty/dists/el9/aarch64/pool/p/patroni/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm
pigsty/dists/el9/aarch64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm
pigsty/dists/el9/aarch64/pool/p/pgbouncer/pgbouncer-1.25.2-42PGDG.rhel9.6.aarch64.rpm
pigsty/dists/el9/x86_64/pool/a/armadillo/armadillo-10.8.2-3.el9.x86_64.rpm
pigsty/dists/el9/x86_64/pool/e/etcd/etcd-3.5.12-1.el8.x86_64.rpm
pigsty/dists/el9/x86_64/pool/e/etcd/etcd-3.5.30-1.el9.x86_64.rpm
pigsty/dists/el9/x86_64/pool/e/etcd/etcd-debuginfo-3.3.11-4.el8.x86_64.rpm
pigsty/dists/el9/x86_64/pool/e/etcd/etcd-debugsource-3.3.11-4.el8.x86_64.rpm
pigsty/dists/el9/x86_64/pool/p/patroni/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm
pigsty/dists/el9/x86_64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm

The x86_64 view carries x86_64 plus noarch; the aarch64 view carries aarch64 plus noarch. noarch is not a third architecture — it is a neutral projection that lands in every applicable view.

Those view files are hardlinks to the root pool, not copies:

stat -c "%h %n" pigsty/pool/p/pev2/pev2-1.22.0-1.noarch.rpm \
                pigsty/dists/el9/x86_64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm \
                pigsty/dists/el9/aarch64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm
3 pigsty/pool/p/pev2/pev2-1.22.0-1.noarch.rpm
3 pigsty/dists/el9/x86_64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm
3 pigsty/dists/el9/aarch64/pool/p/pev2/pev2-1.22.0-1.noarch.rpm

One inode, three names. A noarch package appearing in two views costs its size once on disk.

Why the views exist

The obvious alternative was to leave one physical pool and point package locations at ../../../pool/.... That was tested against real clients and rejected: dnf makecache, repoquery, download and install all worked, but dnf reposync refused, because the normalized local target escaped its per-repository download root.

The shipped layout writes safe relative hrefs with no .. in them:

gzip -dc pigsty/dists/el9/x86_64/repodata/*-primary.xml.gz | grep -o '<location href="[^"]*"'
<location href="pool/a/armadillo/armadillo-10.8.2-3.el9.x86_64.rpm"
<location href="pool/e/etcd/etcd-3.5.12-1.el8.x86_64.rpm"
<location href="pool/e/etcd/etcd-3.5.30-1.el9.x86_64.rpm"
<location href="pool/e/etcd/etcd-debuginfo-3.3.11-4.el8.x86_64.rpm"
<location href="pool/e/etcd/etcd-debugsource-3.3.11-4.el8.x86_64.rpm"
<location href="pool/p/patroni/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm"
<location href="pool/p/pev2/pev2-1.22.0-1.noarch.rpm"

Everything resolves inside the view directory, so reposync mirrors the repository correctly.

Step 6: Filter the noise

Right now the repository ships debuginfo packages and two versions of etcd. Both are usually wrong for a delivery repository. Membership policy lives in sow.yml, not on the command line, so the rules are reviewable and reproducible.

Edit ~/repo/sow.yml:

schema: sow/v2
architectures:
  - x86_64
  - aarch64

repos:
  pigsty:
    dists:
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource]

limit: 1 keeps the newest version of each (name, native architecture) pair, compared with RPM’s own EVR rules. exclude drops packages by classification; kind is derived from the binary name suffix, so -debuginfo and -debugsource are recognized without you writing globs.

Validate before you touch the tree:

sow config check
configuration valid: /home/you/repo repositories=1 dists=1

The configuration is valid, but the built tree no longer matches it:

sow status
repository=pigsty status=dirty ready_to_copy=false revision=2 generation=2 dirty_dists=el9 pending=0/0 locked=false

dirty means the desired state moved ahead of what is on disk. The old tree is still complete and still serves correctly — clients see the previous generation until you converge.

sow build
{"operation":"2673156477918637099","repository":"pigsty","dists":["el9"],"desired_revision":3,"built_generation":3,"noop":false,"dirty":false}
sow ls -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
sha256:7ce1effe2897a6cd1a31849bdb2e315b53186a1bb09ed71d8d489190795cded2	rpm:armadillo-0:10.8.2-3.el9.x86_64	el9	el9	pool/a/armadillo/armadillo-10.8.2-3.el9.x86_64.rpm
sha256:ceb1b8660f8bc1fe59fb7a28e750e19a1ccd010a254a50e82328adb5818a5943	rpm:blackbox_exporter-0:0.28.0-1.aarch64	el9	el9	pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
sha256:a905f9918f4ad224b3eb7fd6bafed50a578e3d321a2900fc38a642af6f342e0a	rpm:etcd-0:3.5.30-1.el9.x86_64	el9	el9	pool/e/etcd/etcd-3.5.30-1.el9.x86_64.rpm
sha256:077938eac0fae939368887e4f20e55e2af7dfb9f0e885869df8841213bd97fd6	rpm:patroni-0:4.1.4-1PGDG.rhel9.6.noarch	el9	el9	pool/p/patroni/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm
sha256:a8456bb578f82d28b1beebc4d756bad4a508a3e4944ef57dc7e2fd048882423b	rpm:pev2-0:1.22.0-1.noarch	el9	el9	pool/p/pev2/pev2-1.22.0-1.noarch.rpm
sha256:5d0e1b7a72c72b37fab5047a85e4dddecd17d41e376b96300706b68f1b3d3607	rpm:pgbouncer-0:1.25.2-42PGDG.rhel9.6.aarch64	el9	el9	pool/p/pgbouncer/pgbouncer-1.25.2-42PGDG.rhel9.6.aarch64.rpm

Six members instead of nine. The two debuginfo packages and the older etcd are gone from the indexes. Their bytes stay in pool/ — SOW removes membership, not payload — so nothing is destroyed by a policy change you might want to reverse.

Now re-run the same add and watch how policy reports itself:

sow add ~/packages/rpm -d el9
add repository=pigsty operation=3213883523634766313 accepted=6 failed=0 memberships=+0/-0 revision=3 generation=3 dirty=false
item input="/home/you/packages/rpm/armadillo-10.8.2-3.el9.x86_64.rpm" status=reused format=rpm coordinate="armadillo-0:10.8.2-3.el9.x86_64" sha256:7ce1effe2897a6cd1a31849bdb2e315b53186a1bb09ed71d8d489190795cded2 dists=el9:accepted
item input="/home/you/packages/rpm/blackbox_exporter-0.28.0-1.aarch64.rpm" status=reused format=rpm coordinate="blackbox_exporter-0:0.28.0-1.aarch64" sha256:ceb1b8660f8bc1fe59fb7a28e750e19a1ccd010a254a50e82328adb5818a5943 dists=el9:accepted
item input="/home/you/packages/rpm/etcd-3.5.12-1.el8.x86_64.rpm" status=excluded format=rpm coordinate="etcd-0:3.5.12-1.el8.x86_64" sha256:bc795bdd732112c36eecffa1e6f94f6c093f5deca56d71d0026ec61e89893f91 dists=el9:limited
item input="/home/you/packages/rpm/etcd-3.5.30-1.el9.x86_64.rpm" status=reused format=rpm coordinate="etcd-0:3.5.30-1.el9.x86_64" sha256:a905f9918f4ad224b3eb7fd6bafed50a578e3d321a2900fc38a642af6f342e0a dists=el9:accepted
item input="/home/you/packages/rpm/etcd-debuginfo-3.3.11-4.el8.x86_64.rpm" status=excluded format=rpm coordinate="etcd-debuginfo-0:3.3.11-4.el8.x86_64" sha256:3dcee7ab93e67cf5ec4cd6a2dff2c1e4f8d189cc1751654d2fb75503cee96475 dists=el9:excluded
item input="/home/you/packages/rpm/etcd-debugsource-3.3.11-4.el8.x86_64.rpm" status=excluded format=rpm coordinate="etcd-debugsource-0:3.3.11-4.el8.x86_64" sha256:f6020dbfd40c3d68c3dc1adefbfd39f304944c7c8268c4e206139ca589d02110 dists=el9:excluded
item input="/home/you/packages/rpm/patroni-4.1.4-1PGDG.rhel9.6.noarch.rpm" status=reused format=rpm coordinate="patroni-0:4.1.4-1PGDG.rhel9.6.noarch" sha256:077938eac0fae939368887e4f20e55e2af7dfb9f0e885869df8841213bd97fd6 dists=el9:accepted
item input="/home/you/packages/rpm/pev2-1.22.0-1.noarch.rpm" status=reused format=rpm coordinate="pev2-0:1.22.0-1.noarch" sha256:a8456bb578f82d28b1beebc4d756bad4a508a3e4944ef57dc7e2fd048882423b dists=el9:accepted
item input="/home/you/packages/rpm/pgbouncer-1.25.2-42PGDG.rhel9.6.aarch64.rpm" status=reused format=rpm coordinate="pgbouncer-0:1.25.2-42PGDG.rhel9.6.aarch64" sha256:5d0e1b7a72c72b37fab5047a85e4dddecd17d41e376b96300706b68f1b3d3607 dists=el9:accepted

Nothing changed: memberships=+0/-0, generation=3 unchanged. Re-adding the same package is a stable no-op, which is what you want from a nightly job. Per-package outcomes are explicit: reused for bytes already present, excluded for a rule hit, and per-Dist limited for a version pushed out by limit.

Step 7: Batch changes without republishing

add and rm build by default. When you are staging a large batch, defer the build with --skip and converge once at the end.

sow add ~/packages/gdal311-3.11.0-2.rhel9.x86_64.rpm -d el9 --skip
add repository=pigsty operation=7821893517298386853 accepted=1 failed=0 memberships=+1/-0 revision=4 generation=3 dirty=true
item input="/home/you/packages/gdal311-3.11.0-2.rhel9.x86_64.rpm" status=accepted format=rpm coordinate="gdal311-0:3.11.0-2.rhel9.x86_64" sha256:9d245f1e2c5e44543e834f2cbff4d57a11938cb3040577cbb0f12edb0fa1baeb dists=el9:accepted
sow status
repository=pigsty status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=el9 pending=1/497416 locked=false

The new package is durably stored in a private pending area (pending=1/497416 — one object, 497 KB). The public tree has not changed by a single byte; clients still see generation 3.

sow check is the gate that refuses to let you publish a half-staged tree:

sow check
repository=pigsty status=dirty ready_to_copy=false revision=4 generation=3
config	ok=true	checked=3
state	ok=true	checked=1
public-modes	ok=true	checked=64
package-bytes	ok=true	checked=10
desired-membership	ok=true	checked=7
index	ok=true	checked=1
signature	ok=true	checked=18
generation-manifest	ok=true	checked=3
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

Exit code 5. Every layer verified clean — nothing is broken — but the tree is not deliverable because the desired state is ahead of it. Converge:

sow build -d el9
sow status
{"operation":"8823464502290701703","repository":"pigsty","dists":["el9"],"desired_revision":4,"built_generation":4,"noop":false,"dirty":false}
repository=pigsty status=clean ready_to_copy=true revision=4 generation=4 dirty_dists= pending=0/0 locked=false

Step 8: Configure the dnf client

Publish ~/repo/pigsty/ over HTTP — see Serve Repositories for a tested Nginx configuration. Assume it is reachable at https://repo.example.com/pigsty/.

The repository URL is the architecture view: dists/<dist>/$basearch. On Enterprise Linux $basearch expands to exactly x86_64 or aarch64, which is why the RPM views use those names — one .repo file covers both architectures.

Write /etc/yum.repos.d/pigsty.repo:

[pigsty-el9]
name=Pigsty EL9 - $basearch
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch
enabled=1
gpgcheck=0
repo_gpgcheck=0
metadata_expire=300
[pigsty-el9]
name=Pigsty EL9 - $basearch
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty
metadata_expire=300
[pigsty-el7]
name=Pigsty EL7 - $basearch
baseurl=https://repo.example.com/pigsty/dists/el7/$basearch
enabled=1
gpgcheck=0
repo_gpgcheck=0

The first tab is what works right now, because you have not signed anything yet. Use it to get the plumbing right, then switch to the second tab after following Sign Your Repository — that tutorial covers generating the key, publishing RPM-GPG-KEY-pigsty, and what gpgcheck and repo_gpgcheck each verify.

metadata_expire=300 is a convenience while you iterate. Raise it once the repository settles.

Step 9: Verify from a client

dnf clean all
dnf makecache
dnf repoquery --repo=pigsty-el9 --queryformat '%{name}-%{evr}.%{arch}'
dnf install -y pev2

makecache fetching repomd.xml and the three checksum-named files means the metadata is well-formed. repoquery returning your package list means primary.xml.gz parsed correctly. install succeeding means the location href values resolved.

This configuration has been verified against AlmaLinux 8, 9, and 10 with dnf4, and against CentOS 7 with yum 3.4.3, which parses multi-version NEVRA lists correctly. The full matrix is in Compatibility.

Mirroring with reposync

Because the views use safe relative hrefs, dnf reposync mirrors the repository intact:

reposync --repo=pigsty-el9 --download-metadata --downloadcomps -p /srv/mirror

The mirror reproduces the pool/ layout beneath the view. This is the check that failed under the rejected layout and passes under the shipped one.

Where to go next

For command syntax and exit codes, see CLI Commands and Exit Codes. For the complete directory tree including the internals under .sow/, see Repository Layout.

Last modified: 2026-08-08: init commit (fe725aa)