Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

Tutorials

Build, sign, serve, and update RPM and DEB repositories.

These tutorials cover repository creation, signing, publication, and updates. Start with Installation if you have not installed SOW.

Create RPM architecture views, filter packages, set version limits, and configure DNF.

Use both package formats and architectures, test installation, and maintain rolling channels, stable releases, and monthly snapshots.

Follow the examples

Run commands in order. Replace uppercase placeholders and /path/to/ inputs with your values. Examples under /srv require a writable directory; you can substitute a path under your home directory.

The YUM and APT tutorials each start with a new workspace. To combine them, create the second Dist in the existing pigsty Repository and keep both Dist definitions in sow.yml.

Each tutorial shows how to check its result. For a command error or interrupted operation, see Troubleshooting.

1 - Build a YUM Repository

Create a managed RPM repository, apply membership policy, serve it, and configure dnf.

This tutorial creates a new Managed RPM repository. Install SOW and Python 3, and prepare RPM files built for your clients. Use an empty /srv/sow directory that your user can create and write to, or substitute another writable path.

1. Create the workspace

mkdir -p /srv/sow
cd /srv/sow
sow init .
sow repo new pigsty
sow dist new el9 --format rpm -r pigsty

el9 names this RPM package set. You can choose another name; it does not change which operating systems the packages support.

2. Set membership policy

Edit the generated sow.yml. This example keeps one version per package and architecture and excludes debug and LLVM JIT packages:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  pigsty:
    dists:
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]
targets: {}

Check the configuration:

sow config check

exclude runs before limit. SOW includes noarch packages in every enabled architecture view. Keep only CPU architecture names in architectures.

3. Add RPMs

sow add /path/to/packages/*.rpm -r pigsty -d el9
sow check -r pigsty

Replace the input path with your RPM directory. add stores accepted packages in the pool, updates membership, and builds the indexes. Review its per-package output: excluded files are reported without failing the whole command.

The public tree has this shape:

/srv/sow/pigsty/
├── pool/...
└── dists/el9/
    ├── x86_64/repodata/...
    └── aarch64/repodata/...

RPM indexes point back to pool/ through relative paths. Serve or copy the complete pigsty/ tree so clients can download the packages.

4. Preview over HTTP

Run this in a separate terminal and leave it running:

python3 -m http.server --bind 127.0.0.1 --directory /srv/sow/pigsty 8080

The preview serves only the Repository root and is reachable from the same host. For a persistent service or clients on other hosts, use Serve Repositories.

5. Configure dnf

On the Linux host running the preview, save this as /etc/yum.repos.d/pigsty.repo. For a remote client, first publish the Repository and substitute its public base URL:

# /etc/yum.repos.d/pigsty.repo
[pigsty-el9]
name=Pigsty EL9
baseurl=http://127.0.0.1:8080/dists/el9/$basearch/
enabled=1
gpgcheck=0
repo_gpgcheck=0

Then refresh and query the repository:

sudo dnf --disablerepo='*' --enablerepo=pigsty-el9 makecache --refresh
dnf --disablerepo='*' --enablerepo=pigsty-el9 list available

This example uses unsigned packages and metadata. Follow Sign Your Repository to enable verification. After the preview, remove this source file and stop the Python server.

6. Publish or export

Use sow publish for a filesystem or R2 target. It uploads package files and metadata in the order clients need. That guide also covers manual delivery to isolated hosts.

Some mirroring tools, including default dnf reposync, reject rpm-md package locations that traverse to the root pool. Export a self-contained RPM leaf when such a consumer is required:

sow export rpm-leaf el9 x86_64 /srv/export/pigsty-el9-x86_64 -r pigsty

The destination must be absent or empty. Export copies package files by default. Use --hardlink only on the same filesystem, and keep exported files read-only because they share storage with the source pool.

Update the repository

sow add /path/to/new.rpm -r pigsty -d el9
sow check -r pigsty

add builds the update automatically. With limit: 1, the newest version replaces the previous member for each package and architecture. Run sow build -r pigsty after changing configuration.

To withdraw a package, preview the selection with sow rm PACKAGE_NAME -r pigsty -d el9 --check, then repeat without --check. A package name selects all its versions and architectures; use a SHA-256 reference for one package object.

See Platforms & Integrations for tested clients.

2 - Migrate an Existing YUM Repository

Move a flat RPM repository to SOW while preserving client cache compatibility, package trust, and publication time.

This guide covers a flat directory of RPMs and repodata/, such as a repository previously built with createrepo_c. It uses Plain sow create, without creating a Managed workspace. The publication-time option requires SOW 0.5.0 or later.

1. Prepare a working copy

Back up the current packages and metadata. Make a separate working copy for the migration; copy RPM files without hard links if you plan to re-sign them. Check the installed binary:

SOW="$(command -v sow)"
"$SOW" version

Confirm that the version is 0.5.0 or later.

Check Action
Package layout Plain reads top-level regular RPMs; migrate nested package layouts separately
Client features Plain emits primary/filelists/other XML, not SQLite databases or module streams
Package trust Keep valid upstream signatures, or deliberately replace them with your own key
Index trust If clients use repo_gpgcheck=1, sign and verify the new repomd.xml separately
Existing clients Include clients with cached metadata, using their existing URL and repository ID

If your clients use modular filtering, keep the repository’s existing module_hotfixes=1 setting where needed. Plain metadata does not include module streams or profiles.

2. Preserve publication history

EL7 YUM can reject an index whose largest data timestamp is older than the cached index. Read <data><timestamp> in the current repomd.xml and retained copies, then use the largest value clients may have seen. If the live index was reset to zero, recover that value from earlier copies or publication records. Use data timestamps, not revision or file modification times.

Have the maintenance script serialize updates and save the greatest published timestamp outside the served tree. For changed content, choose:

next time = max(current Unix second, largest known publication time + 1)

Save the chosen timestamp before switching the public index. A failed publication may leave a gap in the sequence. Keep this record when you restore old content or move the repository to another directory. The +1 distinguishes publications; EL7 also accepts equal timestamps.

For unchanged content, reuse the timestamp after checking the indexes, referenced checksums, and signature. Repair a zero or regressed timestamp. Passing the current time on every run changes repomd.xml, so create can no longer report a no-op.

3. Build and sign a candidate

For this one-off example, prepare /srv/yum.candidate as an independent copy. Set HISTORICAL_MAX to the verified maximum from the live index, the saved maximum publication time, and trusted history. Set SIGN_KEY to the full fingerprint of your metadata signing key. The following commands use Python 3 to choose a strictly newer time:

set -euo pipefail
: "${SOW:?Set the path to the tested SOW binary}"
: "${HISTORICAL_MAX:?Set the verified historical maximum Unix second}"
: "${SIGN_KEY:?Set the full metadata signing fingerprint}"
PUBLISH_TIME=$(python3 -c 'import sys,time; h=int(sys.argv[1]); assert 0 <= h < 253402300799; print(max(int(time.time()), h+1))' "$HISTORICAL_MAX")

"$SOW" create /srv/yum.candidate --metadata-timestamp "$PUBLISH_TIME"
gpg --batch --yes --armor --detach-sign --local-user "$SIGN_KEY" \
  --output /srv/yum.candidate/repodata/repomd.xml.asc \
  /srv/yum.candidate/repodata/repomd.xml
gpg --verify /srv/yum.candidate/repodata/repomd.xml.asc \
  /srv/yum.candidate/repodata/repomd.xml

Record PUBLISH_TIME in the maintenance system before publishing the candidate. Compare the fingerprint from gpg --verify with the expected signing key.

--sign-with KEY signs unsigned RPMs and preserves existing signatures, including vendor signatures. To replace every package signature with your key, add --overwrite in the candidate copy. This changes package bytes; create builds indexes from the signed result. Generate the detached index signature after the final create run.

4. Test the candidate

Point a test client’s original repository URL at the candidate directory. Keep its old cache, repository ID, and signature settings. Refresh the index, then download and install a package. This checks cache compatibility, package paths, and signatures.

If you are migrating an automated update script, also check two cases:

  1. With the same inputs, options, and timestamp, a repeat build reports noop=true. Preserve the existing valid detached signature.
  2. After package content changes, the script advances the timestamp and the same client can fetch the new package.

When comparing with createrepo_c, check dependency meaning and client behavior; XML formatting can differ. See Platforms & Integrations.

5. Publish the repository

Plain replaces multiple files; it does not make the whole directory switch atomically. Coordinate package replacement, metadata, detached signatures, and cache visibility in your publication layer. Retain metadata objects that cached clients may still request; raw Plain rebuilds may remove old SOW checksum-named metadata from the directory.

Keep the original backup and the saved maximum publication time. Roll back package content by rebuilding and signing it as a newer publication. Restoring an old repomd.xml beside changed RPMs can reintroduce checksum or timestamp failures.

Use the checks above for Plain repositories; sow check applies to Managed repositories. See sow create for options and Signing for package and metadata signatures.

3 - Build an APT Repository

Create a managed DEB repository with by-hash indexes and configure an APT client.

This tutorial creates a new Managed DEB repository. Install SOW and Python 3, and prepare DEB files built for your clients. Use an empty /srv/sow directory that your user can create and write to, or substitute another writable path.

1. Create the workspace

mkdir -p /srv/sow
cd /srv/sow
sow init .
sow repo new pigsty
sow dist new trixie --format deb -r pigsty

The Dist name trixie becomes the client’s Suites value. You can choose another name; it does not change which distributions the packages support.

2. Set membership policy

Edit the generated sow.yml if you need filtering or version limits:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  pigsty:
    dists:
      trixie:
        format: deb
        limit: 1
        exclude:
          - kind: [dbgsym, dbg]
targets: {}

Then validate it:

sow config check

Use x86_64 and aarch64 in configuration. They generate Debian indexes for amd64 and arm64. Architecture-independent all packages enter both views.

3. Add DEBs

sow add /path/to/packages/*.deb -r pigsty -d trixie
sow check -r pigsty

Replace the input path with your DEB directory. add stores accepted packages once and builds the indexes. The public tree is:

/srv/sow/pigsty/
├── pool/...
└── dists/trixie/
    ├── Release
    └── main/
        ├── binary-amd64/
        │   ├── Packages
        │   ├── Packages.gz
        │   └── by-hash/SHA256/...
        └── binary-arm64/...

Paths in Packages are relative to the Repository root. Serve the complete pigsty/ directory so APT can reach both the indexes and the package files.

4. Preview over HTTP

Run this in a separate terminal and leave it running:

python3 -m http.server --bind 127.0.0.1 --directory /srv/sow/pigsty 8080

The preview serves only the Repository root and is reachable from the same host. For a persistent service or clients on other hosts, use Serve Repositories.

5. Configure APT

On the Linux host running the preview, save this as /etc/apt/sources.list.d/pigsty.sources. Use arm64 in Architectures for ARM64 clients. For a remote client, first publish the Repository and substitute its public URL:

# /etc/apt/sources.list.d/pigsty.sources
Types: deb
URIs: http://127.0.0.1:8080
Suites: trixie
Components: main
Architectures: amd64
Trusted: yes

Then refresh and query it:

sudo apt update
apt-cache policy

The output should include trixie/main as a package source. Trusted: yes is used for this unsigned example. To add signatures, follow Sign Your Repository to sign metadata, install the public key, and configure Signed-By. After the preview, remove the test source file and stop the Python server.

6. Publish the repository

Use sow publish for a filesystem or R2 target. It uploads packages and indexes before updating Release and its signatures. That guide also covers manual delivery to isolated hosts.

Update the repository

sow add /path/to/new.deb -r pigsty -d trixie
sow check -r pigsty

add builds the update automatically. With limit: 1, the newest version replaces the previous member for each package and architecture. Run sow build -r pigsty after changing configuration.

To withdraw a package, preview the selection with sow rm PACKAGE_NAME -r pigsty -d trixie --check, then repeat without --check. A package name selects all its versions and architectures; use a SHA-256 reference for one package object.

See Platforms & Integrations for tested clients.

4 - Sign Your Repository

Sign RPM and APT metadata, optionally sign RPM packages, and enable client verification.

Repository signing has two layers: metadata signatures and RPM package signatures. Configure each layer separately:

Path Output Client control
RPM metadata repodata/repomd.xml.asc repo_gpgcheck=1
APT metadata InRelease and Release.gpg Signed-By
RPM package body embedded RPM signature gpgcheck=1

APT verifies package hashes through the signed Release file, so DEBs need no separate package signature. Start with metadata signing. Add RPM package signing when clients require gpgcheck=1.

This guide uses the pigsty Repository from the YUM or APT tutorial. Run only the commands for the formats and Dists you created.

1. Create a dedicated key

Install GPG first. These commands create an unencrypted test key and export it to a private directory. For an existing protected key, use a passphrase reference as described below.

SIGNING_UID='SOW Repository <[email protected]>'
gpg --batch --pinentry-mode loopback --passphrase '' \
  --quick-generate-key "$SIGNING_UID" rsa3072 sign 2y

FPR="$(gpg --batch --with-colons --list-secret-keys "$SIGNING_UID" \
  | awk -F: '$1 == "fpr" {print $10; exit}')"
test -n "$FPR"

sudo install -d -m 0700 /srv/sow-secrets
sudo chown "$(id -u):$(id -g)" /srv/sow-secrets
gpg --batch --pinentry-mode loopback --passphrase '' --armor \
  --export-secret-keys "$FPR" > /srv/sow-secrets/repo-signing.asc
gpg --armor --export "$FPR" > /srv/sow-secrets/repo-signing.pub
chmod 600 /srv/sow-secrets/repo-signing.asc

SOW reads the private key from /srv/sow-secrets, outside the public Repository. Give the SOW service account access to this directory if it differs from your user. Distribute repo-signing.pub to clients.

2. Configure metadata signing

In /srv/sow/sow.yml, merge these metadata entries into the Repository’s existing signing block. Keep its package policy and Dist definitions. Include only the formats you use:

repos:
  pigsty:
    signing:
      rpm:
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc
      deb:
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc
    dists:
      # existing Dist definitions remain here

Validate the configuration, rebuild, and check:

cd /srv/sow
sow config check
sow build -r pigsty
sow check -r pigsty

For a protected key, add passphrase: env://SOW_METADATA_PASSPHRASE next to key, then supply that environment variable to SOW. You can also use a passphrase file or agent://FINGERPRINT to sign through GPG. See passphrase references. Configuration stores references, not secret values.

3. Optional: inspect the metadata signer

The preceding sow check verifies metadata signatures. To inspect the signer with GPG, run the commands for your Dists:

gpg --verify \
  pigsty/dists/el9/x86_64/repodata/repomd.xml.asc \
  pigsty/dists/el9/x86_64/repodata/repomd.xml

gpg --verify pigsty/dists/trixie/InRelease
gpg --verify \
  pigsty/dists/trixie/Release.gpg \
  pigsty/dists/trixie/Release

Compare the displayed fingerprint with the public key you distribute to clients.

4. Optional: sign RPM package bodies

For a new repository, configure package signing before importing RPMs. Add this block under the existing signing.rpm configuration:

repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: fill
          key: agent://REPLACE_WITH_THE_FINGERPRINT
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc

Replace the placeholder with the 40-hex fingerprint in $FPR. Install rpm and gpg, and make the secret key available in the GPG keyring used by rpm.

fill signs newly imported packages unless their signatures already verify against the configured key or trusted_keys. See RPM signing policy for other modes.

SOW signs a private copy during add; the input file stays unchanged. build checks stored RPMs against the policy but does not re-sign them.

If you already imported RPMs, check their signatures before enabling fill. Keep trusted upstream public keys in trusted_keys. For unsigned or untrusted stored packages, prepare correctly signed packages with new versions and filenames. Alternatively, configure signing in a new repository, then import the originals.

After configuring the policy, import a new package and check the result:

sow config check
sow add /path/to/new.rpm -r pigsty -d el9
sow check -r pigsty

To inspect a resulting package independently, run rpmkeys --checksig on its file in pigsty/pool/.

5. Enable dnf verification

First publish the signed Repository. Then transfer the public key to the client through a trusted channel:

sudo install -m 0644 /path/to/repo-signing.pub /etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty
sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty

Then enable the checks that correspond to what you signed:

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

If you sign only metadata, use repo_gpgcheck=1 and gpgcheck=0. With package checking enabled, install each trusted upstream package key as well as your own key. Then run sudo dnf makecache --refresh and install a package.

6. Enable APT verification

After publishing the signed Repository, install its public key on the client as a dedicated keyring:

sudo gpg --dearmor --yes \
  --output /usr/share/keyrings/pigsty-archive-keyring.gpg /path/to/repo-signing.pub
sudo chmod 0644 /usr/share/keyrings/pigsty-archive-keyring.gpg

Reference it from deb822 configuration and do not set Trusted: yes:

Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/pigsty-archive-keyring.gpg

Run sudo apt update and install a package. If verification fails, compare the client keyring fingerprint with the signer shown by gpg --verify. For ARM64 clients, change Architectures to arm64.

Plain-mode RPM signing

Plain mode can sign RPM package bodies, but it does not sign repository metadata or create an APT Release:

sow create /srv/flat --sign-with 0123456789ABCDEF

Replace 0123456789ABCDEF with your key ID or fingerprint. The matching private key must be available to rpm and GPG. Existing signatures, including upstream signatures, are preserved. To replace all RPM signatures with your key, use --overwrite on a separate working copy. See sow create for key formats.

For an existing YUM repository, follow the migration guide to preserve publication time and sign repomd.xml after the final create run.

Key changes

To rotate a metadata key, distribute the new public key, update the key reference, then run build, check, and publish. Changing the reference or resolved fingerprint marks affected Dists dirty.

For RPM package keys, use fill and keep the old public key in trusted_keys until packages signed by it are withdrawn or replaced. Keep the corresponding client key available during this period. Existing package objects remain unchanged.

Test the final repository with the DNF or APT versions your users run. See Platforms & Integrations for automated coverage.

5 - Serve and Publish Repositories

Serve a public Repository with Nginx and publish verified Generations to a filesystem target.

SOW generates static repository files. This guide publishes a Managed Repository to a separate directory, then serves that directory with Nginx. It continues from First Workspace.

The source is /srv/sow/local; the public copy will be /srv/repo-public/local. Both contain the complete pool/ and dists/ tree. The workspace configuration and private state stay outside the server root.

1. Build and check

cd /srv/sow
sow build -r local
sow check -r local

Continue when check succeeds.

2. Add a filesystem target

Create the target’s parent directory and give the SOW operator write access:

sudo install -d -m 0755 /srv/repo-public
sudo chown "$(id -u):$(id -g)" /srv/repo-public

Use the account that runs sow publish if it differs from your current user. The endpoint must already exist at its physical path; use no symlink components.

Add this target to /srv/sow/sow.yml:

targets:
  public:
    repository: local
    provider: filesystem
    endpoint: file:///srv/repo-public
    prefix: local
    public_endpoint: file:///srv/repo-public/local/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

SOW creates /srv/repo-public/local from endpoint and prefix. The three required booleans declare that this workspace is the source of truth and the only writer for that target. Use a separate prefix for other tools.

This example checks the published files through file:// and declares no intermediary cache. If you later add a proxy or CDN, use its public Repository URL for public_endpoint and its longest cache lifetime for max_cache_ttl. Apply these changes through publish --rebind. SOW uses them to check delivery and decide when old files can be removed.

Publish the Repository:

sow publish public

SOW copies package files and immutable metadata first, then updates client entry points. It checks the result and records the published generation. Repeating publication without changes reports a no-op.

3. Configure Nginx

Install Nginx and add a server configuration like this. Replace repo.example.com with your hostname:

server {
    listen 80;
    server_name repo.example.com;

    root /srv/repo-public;
    autoindex off;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ (^|/)\. {
        deny all;
    }
}

Check and reload the configuration:

sudo nginx -t
sudo nginx -s reload

These commands assume Nginx is already running. If this is a new installation, start it through your operating system’s service manager after nginx -t succeeds.

The client addresses are:

Client Address
DNF baseurl http://repo.example.com/local/dists/el9/$basearch/
APT URIs http://repo.example.com/local
APT Suites / Components bookworm / main

Use the YUM or APT tutorial for the full source configuration. To verify signatures, distribute the public key and follow Sign Your Repository.

4. Check client access

Configure a client with the addresses above. Refresh its indexes and install a package. This checks access to both dists/ and pool/. Apply the same access rules to both directories.

Publish an update

After changing membership or configuration, build any pending changes and publish again:

sow build -r local
sow check -r local
sow publish public

If publication stops, rerun sow publish public to resume it. See sow publish for recovery and target changes, and sow gc for cleanup after the cache grace period.

Deliver to an isolated host

When SOW cannot reach the destination, stop source writers while creating and checking the copy:

  1. Run sow check on the source.
  2. Copy the complete Repository to a new release directory on the destination. Preserve hard links, for example with rsync -aH or tar.
  3. Verify transferred files against sow changes 0 or an archive checksum manifest.
  4. Switch the server’s path to the new release directory.
  5. Keep package and immutable metadata URLs referenced by old indexes reachable until the maximum cache lifetime expires, then clean up the old release.

For paths absent from the new release, the HTTP server can fall back to the previous release directory during that interval.

Copying directly over a live tree can expose indexes before their package files arrive. Prepare the new directory first and switch it as a unit.

Serve a Plain repository

For Plain mode, serve the directory passed to sow create. Keep temporary hidden files inaccessible; the Nginx dotfile rule above does this.

Publish to R2

Use provider: r2 for Cloudflare R2. Configure its endpoint, bucket, credential reference, public URL, and cache lifetime; see the target configuration reference. Run an initial publication to a test prefix and check it from your package clients.

R2 target garbage collection reports deletion candidates; SOW does not delete those objects. See Platforms & Integrations for tested storage operations.

6 - Build the pigsty-infra Repository

Turn an existing dual-architecture RPM and DEB package pool into an infra repository, then validate installs, roll updates, promote to Stable, and take monthly snapshots.

pgsty/infra-pkg is the upstream build source for Pigsty Infra packages. This tutorial uses already-built RPMs and DEBs for x86-64 and ARM64. It creates an infra Repository with rolling channels, stable releases, and monthly snapshots. Install SOW, jq, and Docker first. The installation examples use pg-exporter; include that package and any dependencies not already in the client images.

1. Put the packages under ~/repo

Use a new ~/repo directory for this workspace. Copy packages from your infra-pkg checkout into two input directories:

mkdir -p ~/repo/packages/rpm ~/repo/packages/deb
cp ~/pgsty/infra-pkg/dist/rpm/*.rpm ~/repo/packages/rpm/
cp ~/pgsty/infra-pkg/dist/deb/*.deb ~/repo/packages/deb/

Include both x86-64 and ARM64 packages. Step 3 checks their formats and architectures from package metadata.

2. Create the infra Repository and two Dists

Initialize a Workspace and create the Repository named infra:

sow init ~/repo
cd ~/repo
sow repo new infra
sow dist new rpm --format rpm -r infra
sow dist new deb --format deb -r infra

Set limit: 1 for both Dists in ~/repo/sow.yml:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  infra:
    dists:
      rpm:
        format: rpm
        limit: 1
      deb:
        format: deb
        limit: 1

limit: 1 keeps only the newest version for each package name and native architecture. The rpm and deb Dists are therefore rolling latest channels while still retaining both x86-64 and ARM64.

sow config check

3. Ingest once and build once

Import both package formats, then build their indexes together:

cd ~/repo
sow add ~/repo/packages/rpm --recursive -r infra -d rpm --skip
sow add ~/repo/packages/deb --recursive -r infra -d deb --skip
sow build -r infra -d rpm -d deb
sow check -r infra

After sow check succeeds, list the formats and architectures read from package metadata:

sow ls -r infra -d rpm -d deb --json |
  jq -r '.result.packages | group_by(.format + "/" + .canonical_arch)[] |
    "\(.[0].format)\t\(.[0].canonical_arch)\t\(length) packages"'

The output must include at least:

deb     aarch64   ... packages
deb     x86_64    ... packages
rpm     aarch64   ... packages
rpm     x86_64    ... packages

SOW reports canonical architecture names, so DEB amd64/arm64 appear here as x86_64/aarch64.

4. Read the generated filesystem tree

The important structure is:

~/repo/
├── sow.yml                            # configuration; never serve it
├── .sow/                              # database, locks, recovery; never serve it
├── packages/                          # original input pool; archive as desired
│   ├── rpm/
│   └── deb/
└── infra/                             # complete public Repository Root
    ├── pool/                          # one shared payload pool for RPM and DEB
    └── dists/
        ├── rpm/
        │   ├── x86_64/repodata/
        │   └── aarch64/repodata/
        └── deb/
            ├── Release
            └── main/
                ├── binary-amd64/
                └── binary-arm64/

SOW places all Dists under dists/ and packages under pool/. Publish or mount the complete ~/repo/infra tree so every Dist can reach its package files.

5. Serve the repository read-only with Nginx

Use the official nginx:alpine image and mount the Repository Root read-only at /infra:

docker network create --internal infra-lab
docker run --detach \
  --name infra-nginx \
  --network infra-lab \
  --publish 127.0.0.1:8080:80 \
  --volume "$HOME/repo/infra:/usr/share/nginx/html/infra:ro" \
  nginx:alpine

Nginx can see only ~/repo/infra; it cannot see sow.yml or .sow/, and it cannot modify the repository.

6. Install an RPM from only infra on EL9

The Rocky Linux 9 container below is attached to the --internal network. The script removes every preconfigured repository and enables only infra, so a successful installation cannot fall back to a public mirror.

docker run --rm --interactive --network infra-lab rockylinux:9 bash -s <<'ROCKY'
set -euxo pipefail

rm -f /etc/yum.repos.d/*.repo
cat >/etc/yum.repos.d/infra.repo <<'REPO'
[infra]
name=Pigsty Infra RPM
baseurl=http://infra-nginx/infra/dists/rpm/$basearch/
enabled=1
gpgcheck=0
repo_gpgcheck=0
REPO

dnf clean all
dnf --disablerepo='*' --enablerepo=infra makecache
dnf --disablerepo='*' --enablerepo=infra install -y pg-exporter
rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n' pg-exporter
command -v pg_exporter
ROCKY

An RPM baseurl must point to a concrete architecture view. dnf expands $basearch to x86_64 or aarch64.

7. Install a DEB from only infra on Ubuntu 24.04

APT points URIs at the Repository Root and uses the Dist name deb as Suites:

docker run --rm --interactive --network infra-lab ubuntu:24.04 bash -s <<'UBUNTU'
set -euxo pipefail

rm -f /etc/apt/sources.list
rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources
cat >/etc/apt/sources.list.d/infra.sources <<'SOURCE'
Types: deb
URIs: http://infra-nginx/infra
Suites: deb
Components: main
Trusted: yes
SOURCE

apt-get clean
apt-get update
apt-get install -y --no-install-recommends pg-exporter
dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' pg-exporter
command -v pg_exporter
UBUNTU

The lab uses unsigned repositories. Follow Sign Your Repository to enable repo_gpgcheck=1 for RPM metadata and Signed-By for APT. Use gpgcheck=1 when RPM package signatures are also available. Remove Trusted: yes from the signed APT source.

Docker containers use the host architecture by default. To test the other architecture, add --platform linux/amd64 or --platform linux/arm64 to the corresponding docker run. Cross-architecture execution requires binfmt/QEMU support.

8. Routine maintenance: add a new version

To update the repository, run add with the new packages. Suppose the four new pg-exporter payloads are ready:

cp ~/pgsty/infra-pkg/dist/rpm/pg-exporter-*.rpm ~/repo/packages/rpm/
cp ~/pgsty/infra-pkg/dist/deb/pg-exporter_*.deb ~/repo/packages/deb/

cd ~/repo
sow add ~/repo/packages/rpm/pg-exporter-*.rpm -r infra -d rpm --skip
sow add ~/repo/packages/deb/pg-exporter_*.deb -r infra -d deb --skip
sow build -r infra -d rpm -d deb
sow check -r infra

Because the latest Dists have limit: 1, the new version wins and the old version automatically leaves that Dist’s Desired Membership. The old bytes are not deleted immediately, and relaxing policy later does not make the old membership reappear automatically.

Rerun the EL9 and Ubuntu clients from sections 6 and 7, refresh metadata, then install or upgrade to complete the update acceptance test.

To withdraw a bad package, use sow ls -r infra -d rpm --json to find its SHA-256. Preview with sow rm sha256:DIGEST -r infra -d rpm --check, then repeat without --check. Use -d deb for DEBs. A SHA-256 reference selects one package object; a bare name selects all its versions and architectures. sow gc handles unused pool files separately.

9. Two retention layers: latest and stable

limit: 1 makes rpm and deb good rolling channels but cannot express “keep every formally released version.” Create two more Dists for that purpose:

cd ~/repo
sow dist new rpm-stable --format rpm -r infra
sow dist new deb-stable --format deb -r infra
sow config show -r infra -d rpm-stable -d deb-stable

The new Dists default to limit: 0, which means retain every version. The resulting policy is:

Dist Format limit Role
rpm RPM 1 RPM latest
deb DEB 1 DEB latest
rpm-stable RPM 0 Accumulate promoted RPMs
deb-stable DEB 0 Accumulate promoted DEBs

Stable accumulates the versions you explicitly promote after creating the Dists.

10. Promote latest into stable

Promote packages by adding the source Dist’s pool files to the target Dist. SOW verifies and reuses the existing package objects, so promotion adds no second copy. Pause other repository writers before exporting the manifests. Resume them after promotion finishes.

First require clean source state and save the promotion manifests:

set -euo pipefail
cd ~/repo
sow check -r infra
mkdir -p ~/repo/manifests

sow ls -r infra -d rpm --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/rpm-latest-202608.list
sow ls -r infra -d deb --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/deb-latest-202608.list

Add the recorded pool objects to the stable Dists:

cd ~/repo
(
  set -euo pipefail
  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d rpm-stable --skip
  done < ~/repo/manifests/rpm-latest-202608.list

  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d deb-stable --skip
  done < ~/repo/manifests/deb-latest-202608.list

  sow build -r infra -d rpm-stable -d deb-stable
  sow check -r infra
)

Each add should report reused. If the loop stops midway, the source Dists are unchanged; correct the failure and rerun the same manifest. Over subsequent promotions, rpm/deb retain only the latest version while rpm-stable/deb-stable accumulate release history.

11. Take the 2026-08 snapshot from stable

A client-visible monthly snapshot is another pair of Dists:

cd ~/repo
sow dist new rpm-202608 --format rpm -r infra
sow dist new deb-202608 --format deb -r infra
sow config show -r infra -d rpm-202608 -d deb-202608

Pause other repository writers while creating the snapshot. First save the stable package lists:

set -euo pipefail
sow check -r infra
sow ls -r infra -d rpm-stable --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/rpm-stable-202608.list
sow ls -r infra -d deb-stable --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/deb-stable-202608.list

Add those manifests to the corresponding snapshot Dists:

cd ~/repo
(
  set -euo pipefail
  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d rpm-202608 --skip
  done < ~/repo/manifests/rpm-stable-202608.list

  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d deb-202608 --skip
  done < ~/repo/manifests/deb-stable-202608.list

  sow build -r infra -d rpm-202608 -d deb-202608
  sow check -r infra
)

To keep this build after you eventually remove the snapshot Dists, also retain the current Repository Generation:

sow retain add "$(sow status -r infra --json | jq -r '.result.built_generation')" -r infra
sow retain ls -r infra

retain stores this build’s metadata and protects its package files from garbage collection. Clients still use the snapshot Dists. SOW does not make these Dists read-only; keep their membership and effective configuration unchanged to preserve their contents.

12. Client address map

One Nginx service and one shared infra/pool support every channel:

Channel dnf baseurl APT URIs / Suites
latest http://infra-nginx/infra/dists/rpm/$basearch/ http://infra-nginx/infra / deb
stable http://infra-nginx/infra/dists/rpm-stable/$basearch/ http://infra-nginx/infra / deb-stable
2026-08 http://infra-nginx/infra/dists/rpm-202608/$basearch/ http://infra-nginx/infra / deb-202608

Use sow dist ls -r infra to see all six Dists.

The rolling Dists keep the latest packages, stable Dists accumulate promoted releases, and monthly Dists keep the selected snapshot. All views share the same package pool.

Stop the temporary service when the lab is complete:

docker rm --force infra-nginx
docker network rm infra-lab