Skip to content

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

Return to the regular view of this page.

SOW Docs

Create and manage RPM/YUM and DEB/APT repositories with one self-contained binary.

SOW is Pigsty’s self-contained package repository manager. sow create turns the RPM and DEB files in a directory into a usable flat repository. Managed workspaces add package membership, policy, signing, immutable generations, audit history, and publication targets.

Press Ctrl with K (or with K on macOS) to search this site. Press / outside an input to open command mode directly.

  • Get Started — Install SOW, create a flat repository, and build the first Managed workspace.
  • Tutorials — Complete YUM, APT, signing, serving, and publication walkthroughs.
  • Features — Plain and Managed execution, pool projections, policy, signing, transactions, and audit.
  • Design Records — Dated ownership, layout, publication, recovery, and compatibility decisions.
  • Commands — Syntax, selection rules, output, state transitions, and exit behavior for every command.
  • Reference — Configuration, package references, layouts, JSON, exit codes, platforms, and integration coverage.

Choose a path

Goal Start here
Index a package directory now Quick Start
Maintain a curated long-lived repository First Workspace
Build a complete YUM or APT repository Tutorials
Look up exact CLI behavior Commands
Check a field, path, or compatibility claim Reference
Understand an architecture decision Design Records

1 - Get Started

Install SOW, create a flat repository, and learn the Managed workspace model.

SOW builds static RPM/YUM and DEB/APT repositories; it is not an HTTP daemon. Choose one of two isolated paths:

  • Plain: sow create rebuilds indexes beside the packages in an ordinary directory.

  • Managed: a workspace tracks package membership, Dists, architecture views, policy, signing, generations, audit history, and publication targets.

  • Installation — Choose a release archive, RPM/DEB package, or source build; verify the installed binary.

  • Quick Start — Create and serve a flat repository from a directory of packages.

  • First Workspace — Initialize Managed mode, create RPM and DEB Dists, add packages, build, and check.

  • Core Concepts — Workspace, Repository, Dist, Package Object, Desired Membership, and Built Generation.

Managed workspaces require a local POSIX filesystem with advisory locks, fsync, and atomic rename semantics. Metadata generation is in-process; optional RPM package signing needs rpm, while an agent:// metadata key needs gpg and gpg-agent.

1.1 - Installation

Install SOW from an archive, RPM/DEB package, or source, then verify the binary and filesystem requirements.

SOW is one executable: there is no service to enable and no runtime language environment. Release builds target Linux and macOS on amd64 and arm64; Linux also gets RPM and DEB packages. Windows is not supported.

Use the Download page to select the archive or Linux package that matches your operating system and architecture. It links each published artifact, its source tag, and SHA256SUMS.

Install an archive

Download one archive plus SHA256SUMS, then verify the matching line before extraction:

# Linux amd64
grep 'sow_.*_linux_amd64.tar.gz$' SHA256SUMS | sha256sum -c -
tar -xzf sow_*_linux_amd64.tar.gz
sudo install -m 0755 sow /usr/local/bin/sow

On macOS, select darwin_amd64 or darwin_arm64 and replace sha256sum -c - with shasum -a 256 -c -. Without root, install to a directory already on your PATH, such as ~/.local/bin.

Install a Linux package

Linux packages use the 1PGSTY release suffix:

sudo rpm -Uvh ./sow-*-1PGSTY.x86_64.rpm
sudo apt install ./sow_*-1PGSTY_amd64.deb

Choose only the command and architecture that match the host. RPM installs the license at /usr/share/licenses/sow/LICENSE; DEB installs copyright/license metadata under /usr/share/doc/sow/.

Build from source

The module declares Go 1.27.0. Metadata generation needs no C toolchain. Replace vX.Y.Z with the source tag linked from the Download page:

git clone https://github.com/pgsty/sow.git
cd sow
set -euo pipefail
SOW_TAG=vX.Y.Z
git checkout "$SOW_TAG"
SOW_VERSION="${SOW_TAG#v}"
CGO_ENABLED=0 go build -trimpath \
  -ldflags="-s -w -X github.com/pgsty/sow/internal/v2cli.Version=${SOW_VERSION}" \
  -o sow ./cmd/sow
sudo install -m 0755 sow /usr/local/bin/sow

This uses the release build flags and embeds the selected tag’s product version.

Verify

sow version
sow help

sow version reports product version, target OS/architecture, and build Go toolchain. sow help lists the command tree. Each archive also contains README.md, CHANGELOG.md, and the Apache-2.0 LICENSE.

Upgrade a 0.3 Managed Workspace

SOW 0.4 introduces internal database schemas v11 and v12. The public layout and schema: sow/v3 configuration identifier stay the same, but each existing v0.3 Repository must be migrated explicitly before ordinary reads or writes. Stop Workspace writers before taking the backup:

cp -a /srv/sow /srv/sow.backup-before-0.4.0
sow repo migrate REPOSITORY -C /srv/sow
sow check -r REPOSITORY -C /srv/sow

Repeat the last two commands for every Repository named in sow.yml. The database transition is one-way; do not reopen a migrated Workspace with SOW 0.3. See sow repo migrate for the repaired status, signer, and publication evidence.

Permissions and optional tools

The invoking user needs read access to package inputs and write access to the Plain target or Managed workspace. Keep Managed workspaces on a local POSIX filesystem: locks, fsync, safe paths, and atomic rename are part of the correctness contract.

Repository parsing and metadata rendering are in-process. Only two optional paths need host tools:

  • RPM package signing requires rpm and a working GPG environment;
  • an agent:// metadata key requires gpg and gpg-agent.

Next: Quick Start for Plain mode, or First Workspace for Managed mode.

1.2 - Quick Start

Index a directory of RPM and DEB packages, serve it, and configure a client.

Plain mode builds a flat repository in one directory. It does not read sow.yml, create a workspace, or keep a database.

Prepare a directory

Put RPM and/or DEB files at the directory top level. sow create does not recurse and does not move or rename package files.

mkdir -p /srv/repo
cp /path/to/packages/*.rpm /path/to/packages/*.deb /srv/repo/

If one glob has no matches, copy the formats you actually have instead.

Generate metadata

sow create /srv/repo

An illustrative mixed-format result is:

created /srv/repo: rpm=1 deb=1 signed=0 removed=0 marker=false noop=false recovered=false

The directory now contains:

/srv/repo/
├── package.rpm
├── package.deb
├── repodata/       # RPM: repomd.xml plus primary, filelists, other
├── Packages        # DEB flat index
└── Packages.gz

Plain mode does not generate a DEB Release, InRelease, or Release.gpg. RPM and DEB metadata are generated in one operation; a parse or render failure prevents the new indexes from being committed.

Serve the directory

For a local check, any static file server is sufficient:

cd /srv/repo
python3 -m http.server --bind 127.0.0.1 8080

Verify the protocol entry points from another shell:

curl --fail http://127.0.0.1:8080/repodata/repomd.xml >/dev/null
curl --fail http://127.0.0.1:8080/Packages.gz >/dev/null

Python’s server is only a preview. Use a maintained HTTP server for persistent service.

Configure a client

Replace REPO_HOST with the address clients can reach.

# /etc/yum.repos.d/sow-quickstart.repo
[sow-quickstart]
name=SOW Quick Start
baseurl=http://REPO_HOST:8080/
enabled=1
gpgcheck=0
repo_gpgcheck=0
# /etc/apt/sources.list.d/sow-quickstart.list
deb [trusted=yes] http://REPO_HOST:8080/ ./

Then refresh and install a package:

sudo dnf makecache
sudo dnf install PACKAGE_NAME
sudo apt update
sudo apt install PACKAGE_NAME

The APT source ends in ./ because this is a flat repository. [trusted=yes] and the disabled DNF signature checks are appropriate only for this unsigned quick start. Use a signed Managed repository when authenticity matters.

Update the repository

Change the package files and run the same command again:

sow create /srv/repo

The directory contents are the complete Plain-mode state. With unchanged package bytes, the generated metadata is deterministic and a repeat run reports noop=true.

For automation, request the versioned JSON envelope:

sow create /srv/repo --json

When to use Managed mode

Use Plain mode when the directory already contains exactly what should be published. Use a Managed workspace when you need named Dists, architecture views, membership policy, signed metadata, generations, audit, or publication targets.

See also sow create and Plain Flat Repositories.

1.3 - Your First Workspace

Create a workspace with RPM and DEB Dists, add packages, and verify the public tree.

Managed mode keeps configuration, membership, generations, and audit state. This example starts from an empty directory.

Initialize the workspace

sow init /srv/sow
cd /srv/sow

init creates:

/srv/sow/
├── sow.yml   # configuration; schema: sow/v3
└── .sow/     # SQLite state, locks, staging, recovery, journals

Do not edit or serve .sow/. init is idempotent: rerunning it validates and converges declared repositories and Dists; it does not reset a valid workspace.

The default architecture families are x86_64 and aarch64. Configuration accepts amd64 and arm64 as aliases and normalizes them to those family names.

Create a Repository and two Dists

sow repo new local
sow dist new el9 --format rpm
sow dist new bookworm --format deb

A Repository owns one public pool/ + dists/ tree and one private state database. A Dist has exactly one format. dist new materializes a valid empty view, so empty clients receive an empty index instead of a 404.

The public layout is now:

/srv/sow/local/
├── pool/
└── dists/
    ├── el9/
    │   ├── x86_64/repodata/
    │   └── aarch64/repodata/
    └── bookworm/
        ├── Release
        └── main/
            ├── binary-amd64/{Packages,Packages.gz,by-hash/}
            └── binary-arm64/{Packages,Packages.gz,by-hash/}

Add packages

Select the target Dist explicitly:

sow add /path/to/packages/*.rpm -d el9
sow add /path/to/packages/*.deb -d bookworm

SOW reads identity and architecture from the package itself, stores accepted bytes under local/pool/, updates Desired Membership, and builds affected Dists before returning. The package path is only an input; later builds use the managed pool.

Use --skip to stage several membership changes without rebuilding each time, then converge once:

sow add /path/to/more/*.rpm -d el9 --skip
sow build

While Desired Membership is ahead of the Built Generation, the Repository is dirty and ready_to_copy=false.

Inspect and verify

sow status
sow ls -d el9
sow ls -d bookworm
sow check

status is a cheap state read. check is the delivery gate: it verifies configuration, state, public modes, retained roots, package bytes, Desired Membership, indexes, signatures, and the Generation manifest. It writes nothing. Only a clean Repository that passes all layers returns success.

To see normalized configuration and defaults:

sow config show --all

Serve the Repository

The public unit is /srv/sow/local, not the workspace root. Serve that directory at a stable URL prefix; do not expose sow.yml or .sow/.

  • DNF base URL: https://repo.example.com/local/dists/el9/x86_64/
  • APT source: deb https://repo.example.com/local bookworm main

For a safe Nginx and filesystem-publication workflow, continue with Serve Repositories.

Selection rules

  • Workspace: search upward from the current directory, or start from -C DIR.
  • Repository: -r NAME, the containing Repository, or the only configured Repository.
  • Dist: -d NAME, repeatable; omission is accepted only when the command can resolve an unambiguous scope.

Ambiguity is an error; SOW does not pick an arbitrary Repository or Dist.

Next

1.4 - Core Concepts

The SOW model: Plain and Managed execution, pools and views, Desired Membership, and Built Generations.

Plain or Managed

The two execution paths are separate.

Plain Managed
Entry point sow create DIR init, repo, dist, add, rm, build
State package directory sow.yml plus private SQLite/journals
Public layout flat RPM/DEB indexes Repository pool/ + dists/
Formats RPM and DEB may share one directory one format per Dist
Architecture views no yes
Policy and audit no yes
Metadata signing and publication targets no yes

Choose Plain when directory contents already equal the desired repository. Choose Managed when SOW must own membership, policy, generations, signing, audit, or publication.

Managed hierarchy

Workspace                    /srv/sow
├── sow.yml                  configuration
├── .sow/                    private state; never served
└── Repository               /srv/sow/local
    ├── pool/                canonical package payloads
    └── dists/
        └── Dist             one named RPM or DEB membership set
            └── views        metadata rendered per architecture
  • Workspace is the configuration and discovery boundary.
  • Repository is the isolation, Generation, publication, and public-tree boundary. Repositories do not deduplicate payloads with one another.
  • Dist is a named membership set in exactly one package format.
  • Architecture view is derived output, not a second membership set. noarch RPMs and all DEBs are selected into every applicable view without duplicating their pool bytes.

One canonical payload

A package object is identified by its exact-byte SHA-256. Its logical coordinate comes from the RPM header or DEB control data, not the filename.

Each accepted payload has one canonical path under the Repository pool/. RPM architecture views contain repodata/ only; their package locations use parent-relative paths back to the pool. APT Packages entries name the same pool directly.

Ordinary package clients and mirror tools are different contracts. Default dnf reposync rejects the canonical RPM view’s parent traversal. When a self-contained RPM mirror leaf is required, create a separate artifact with sow export rpm-leaf.

Desired and Built

Managed mode tracks intent and public bytes separately:

add / rm -> Desired Membership (revision)
                    |
                  build
                    v
             Built Generation -> pool/ + dists/

add and rm build affected Dists by default. --skip records membership changes but leaves the Repository dirty; sow build later converges Desired into a new Built Generation.

  • sow status reads state cheaply and reports ready_to_copy.
  • sow check performs the full, read-only delivery proof. Dirty or recovering state is not deliverable.
  • sow changes [BASE_GENERATION] describes the physical difference between a recorded Generation and the current Built Generation. It is evidence and planning output, not a substitute for publication recovery or remote verification.
  • sow publish TARGET publishes a verified Generation through the configured provider and records target-scoped recovery/checkpoint state.

Transactions and failure states

Writes are serialized by Workspace or Repository locks and journal their intent before public mutation. Payloads and immutable metadata are prepared before mutable protocol pointers. A later writer recovers an interrupted operation before starting new work.

The operational states are:

State Meaning
clean Desired and Built agree
dirty Desired changed; Built is still the previous committed Generation
recovering a nonterminal operation must be resolved
error durable evidence conflicts; SOW refuses to guess

Use status to diagnose and check as the release gate. Do not publish a Repository with ready_to_copy=false.

Continue

2 - Tutorials

End-to-end walkthroughs that take a pile of packages all the way to a signed repository your clients can install from.

Each tutorial starts from a new workspace. Commands are intended to be run in order; replace uppercase placeholders and package paths for your environment.

If you have not installed SOW yet, start with Installation and Quick Start. The tutorials below cover the managed repository path.

A managed RPM repository with per-architecture views, noarch projection, debuginfo filtering, version limits, and a working dnf client configuration.

A managed DEB repository with a Debian-style pool, by-hash indexes, and a deb822 client configuration.

Generate a dedicated GPG key, sign repository metadata and RPM packages, and configure clients to reject anything unsigned.

Serve a Repository with Nginx and publish a verified Generation to a configured filesystem target without exposing private workspace state.

Turn existing dual-architecture infra-pkg RPMs and DEBs into a real repository, then rehearse local installation, rolling updates, Stable promotion, and monthly snapshots.

Which one first

Your situation Start here
You ship RPMs to dnf clients Build a YUM Repository
You ship DEBs for Debian or Ubuntu Build an APT Repository
You need signed metadata or signed RPM payloads Sign Your Repository
The tree is built but nothing can reach it Serve Repositories
You want to turn a dual-architecture package pool into a maintained Infra repository Build the pigsty-infra Repository

The YUM and APT tutorials are independent fresh-workspace paths. A real Workspace may hold both RPM and DEB Dists in one Repository when that ownership boundary suits your operation.

Conventions used here

Shell blocks contain commands without a $ prefix so you can copy a whole block at once. Output appears in a separate block below the command, or as a comment when it is one line. Where a command needs a value you must substitute, it appears in UPPERCASE.

Every tutorial ends with a verification step. sow check returning 0 proves the selected Repository is complete and matches the recorded Generation. A nonzero result is not a release artifact.

2.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. You need a writable directory, and one or more RPM files.

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

The Dist name is an identifier chosen by you. SOW does not infer an operating-system release from el9.

2. Set membership policy

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

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

Validate every manual edit before writing repository state:

sow config check
sow config show --all

exclude runs before limit. Neutral noarch packages are projected into every enabled architecture view; they are not listed in architectures.

3. Add RPMs

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

add parses the package headers, stores each accepted package once in the canonical pool, updates Desired membership, and materializes a new Generation. Excluded inputs are reported per item and are not command failures.

The public tree has this shape:

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

The rpm-md location href entries reach package bytes in the root pool/ by relative paths. Do not copy an architecture directory by itself: it is not a standalone repository.

4. Preview over HTTP

For a local preview:

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

Check the entry point from another shell:

curl --fail http://127.0.0.1:8080/pigsty/dists/el9/x86_64/repodata/repomd.xml >/dev/null

Use a maintained HTTP server for persistent service. It must expose the whole pigsty/ tree so client-resolved package URLs under pigsty/pool/ remain reachable.

5. Configure dnf

Replace REPO_HOST with an address the client can reach:

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

Then refresh and query the repository:

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

This configuration is deliberately unsigned. Enable client verification only after following Sign Your Repository.

6. Publish or export

Before delivery, require a successful deep check:

sow check -r pigsty

Use sow publish for a configured filesystem or R2 target. A whole-root copy is acceptable only into an offline staging location that is switched into service atomically; do not run an unordered in-place sync against a live repository.

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. The export duplicates package bytes by default; --hardlink is an explicit same-filesystem, trusted, read-only optimization.

Update the repository

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

add and rm change Desired membership. build is useful after policy or signing configuration changes. check is the publication gate; status alone is not.

The automated client and platform scope is listed under Platforms & Integrations.

2.2 - 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. You need a writable directory, and one or more DEB files.

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 becomes the APT suite. It is an identifier chosen by you; SOW does not infer distribution semantics from trixie.

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
sow config show --all

SOW stores canonical architecture families in configuration and renders Debian names in the repository: x86_64 becomes amd64, aarch64 becomes arm64, and neutral all packages are included in both views.

3. Add DEBs

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

Accepted package bytes are stored once. The public tree is:

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

Package paths beneath pool/ are grouped by normalized source package. Packages uses archive-root-relative Filename values. SOW writes SHA-256 by-hash copies and advertises them from Release.

4. Preview over HTTP

For a local preview:

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

Check the entry points:

curl --fail http://127.0.0.1:8080/pigsty/dists/trixie/Release >/dev/null
curl --fail http://127.0.0.1:8080/pigsty/dists/trixie/main/binary-amd64/Packages.gz >/dev/null

Use a maintained HTTP server for persistent service and expose the complete pigsty/ tree.

5. Configure APT

Replace REPO_HOST with an address the client can reach. For an unsigned test repository, use a deb822 source with explicit trust:

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

Then refresh and query it:

sudo apt update
apt-cache policy

Trusted: yes disables authenticity checking and is suitable only for a controlled test. For a signed repository, remove that line and configure a keyring:

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

Follow Sign Your Repository before enabling Signed-By.

6. Publish safely

Require a successful deep check before delivery:

sow check -r pigsty

Use sow publish for a configured filesystem or R2 target. If you use another transport, copy the entire repository into an offline staging location and switch it into service atomically. Do not update a live dists/ tree file by file: clients may observe metadata and package state from different generations.

Update the repository

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

Use build after policy or signing configuration changes. Use check, not status alone, as the publication gate.

The automated client and platform scope is listed under Platforms & Integrations.

2.3 - Sign Your Repository

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

SOW has two independent signing paths:

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 trusts package hashes through the signed Release; SOW does not re-sign DEB package bodies. Start with metadata signing. Add RPM package signing only when you own the signing policy for those package bytes.

1. Create a dedicated key

The commands below create an unencrypted example key. Use a protected key and a passphrase reference for production; see the configuration reference.

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

Keep the secret key outside the Workspace’s public Repository tree and outside every web root. If a dedicated service account runs SOW, make that account—not the interactive user—the directory owner. Distribute only repo-signing.pub to clients.

2. Configure metadata signing

In /srv/sow/sow.yml, add the relevant blocks under the Repository. Omit the ecosystem you do not 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 key reference, rebuild, and run the publication gate:

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

For a protected key, add passphrase: env://SOW_METADATA_PASSPHRASE or a bounded file reference next to key. SOW never writes key or passphrase material into configuration, SQLite, JSON, or logs.

3. Verify metadata manually

Use the exact paths for your Dists and architectures:

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

sow check verifies the configured signing identity as part of its deeper consistency checks. Manual verification is still useful when establishing a client trust root.

4. Optional: sign RPM package bodies

Add rpm.packages only if clients require embedded package signatures:

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 printed in $FPR. For this operation:

  • rpm and gpg must be installed;
  • the matching secret key must be available in the ambient GPG environment used by rpm;
  • fill preserves packages already signed by the configured or trusted_keys identities;
  • always re-signs everything not already signed by the configured identity;
  • never leaves input bytes unchanged.

SOW invokes rpm --addsign or rpm --resign on a private staged copy, not on the input file. Revalidate and rebuild after changing the policy:

sow config check
sow build -r pigsty
sow check -r pigsty

Inspect a resulting package with rpmkeys --checksig /path/to/package.rpm.

5. Enable dnf verification

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

Set gpgcheck=0 if package-body signing is not configured. Do not disable repo_gpgcheck after configuring metadata signing.

6. Enable APT verification

Install the public key as a dedicated keyring:

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

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 apt update and treat any signature error as a failed deployment, not as a reason to weaken the client configuration.

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

The key must be exactly 16, 40, or 64 hexadecimal characters, with no 0x prefix, and the matching private key must be usable by the ambient rpm/GPG setup. Without --overwrite, already signed RPMs keep their bytes; adding --overwrite explicitly re-signs every RPM. SOW signs private staged copies before replacing package bytes and metadata.

Key changes

Changing a key reference or resolved fingerprint marks affected Dists dirty. A metadata key can be changed by distributing the new public key, rebuilding, checking, and then switching client enforcement. RPM package keys need a staged rollover: Package Objects are immutable, and build rejects stored RPMs that do not satisfy the new policy instead of re-signing them in place. Use fill with the old public key in trusted_keys until old package coordinates have been withdrawn or replaced. Finish with a real client acceptance test in the target environment.

Run the final signed repository through the exact dnf/APT versions and trust policy used in production. The automated scope is listed under Platforms & Integrations.

2.4 - Serve and Publish Repositories

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

SOW writes static files; it is not an HTTP server. This guide keeps the writable Workspace separate from the path Nginx serves.

Public and private paths

Mode Public unit Never serve
Plain the directory passed to sow create transient .sow-plain-stage-* output; no durable journal
Managed one Repository’s complete pool/ + dists/ tree Workspace sow.yml, .sow/, SQLite, locks, journals, staging

For the workspace in First Workspace, the source Repository is /srv/sow/local. Do not make /srv/sow the document root.

1. Gate the source Generation

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

Continue only when check returns 0. status is useful for diagnosis, but check is the full read-only delivery proof.

2. Configure a filesystem target

Create the endpoint directory first. It must be a real, canonical directory, not a symlink; SOW refuses to create a missing endpoint for you.

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

The second command gives the current operator write access; use the account that will run sow publish if publication runs under a dedicated service user.

Add a 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

The three booleans are mandatory safety acknowledgements. The endpoint and prefix combine to /srv/repo-public/local; SOW creates and owns the prefix below the pre-existing endpoint.

Validate and publish:

sow config check
sow publish public

Publication copies immutable payloads and metadata before mutable protocol pointers, verifies the result, and records a target checkpoint. Repeating the command for an unchanged Generation is an idempotent no-op.

Do not let another tool write into the same target prefix. The target contract is single-writer and exclusive.

3. Serve the target with Nginx

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

    root /srv/repo-public;
    autoindex off;

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

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

Reload Nginx after validating its configuration. The client URLs are:

DNF baseurl: http://repo.example.com/local/dists/el9/x86_64/
APT source:  deb http://repo.example.com/local bookworm main

If metadata or packages are signed, publish the corresponding public key separately and configure gpgkey/Signed-By; private keys never belong under the document root.

4. Verify the served entry points

curl --fail --head \
  http://repo.example.com/local/dists/el9/x86_64/repodata/repomd.xml

curl --fail --head \
  http://repo.example.com/local/dists/bookworm/Release

curl --fail --head \
  http://repo.example.com/local/dists/bookworm/main/binary-amd64/Packages.gz

Then run the actual package manager from a client host. HTTP reachability is not package client verification; both checks matter.

The complete Repository prefix must have one access policy. RPM metadata may resolve a package through ../../../pool/..., and APT Filename fields point to pool/... directly. Protecting dists/ while accidentally exposing or blocking pool/ breaks the repository.

Manual and air-gapped delivery

If sow publish cannot reach the destination:

  1. run sow check on the source;
  2. copy the complete Repository into a new, non-live staging or release directory;
  3. verify transport checksums against sow changes 0 or an archive manifest;
  4. atomically switch an operator-owned parent reference to the new directory;
  5. keep the previous release until clients and caches have moved past it.

Do not run an unordered rsync --delete directly against a live Repository root. That does not preserve SOW’s pointer ordering, target checkpoint, cache grace, or recovery state. sow changes describes Generation differences; it is not an authorization to mutate a live target without those controls.

R2 targets

provider: r2 uses an S3-compatible storage transport and report-only target GC. The transport integration covers list, HEAD, GET, and conditional PUT against a pinned MinIO fixture. Validate credentials, bucket policy, public endpoint, cache behavior, replay, and recovery on a nonproduction prefix before enabling a production target. See Platforms & Integrations.

Next

2.5 - 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 assumes the dual-architecture RPMs and DEBs already exist and covers the second half of the job: turning that pile of packages into a real, consumable, maintainable SOW repository named infra.

1. Put the packages under ~/repo

This tutorial uses the fixed path ~/repo throughout. Copy the existing packages 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/

Confirm that all four format-by-architecture cells contain real payloads:

find ~/repo/packages/rpm -maxdepth 1 -type f -name '*.x86_64.rpm' | wc -l
find ~/repo/packages/rpm -maxdepth 1 -type f -name '*.aarch64.rpm' | wc -l
find ~/repo/packages/deb -maxdepth 1 -type f -name '*_amd64.deb' | wc -l
find ~/repo/packages/deb -maxdepth 1 -type f -name '*_arm64.deb' | wc -l

All four results must be greater than zero. At this point, the tree is only an input package pool:

~/repo/
└── packages/
    ├── rpm/                         # x86_64 + aarch64 RPMs
    └── deb/                         # amd64 + arm64 DEBs

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

The model is now fixed:

Repository: infra
├── Dist: rpm    format=rpm    policy=latest
└── Dist: deb    format=deb    policy=latest

Open ~/repo/sow.yml and reduce it to this configuration:

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
sow config show --all -r infra

3. Ingest once and build once

Update Desired Membership first, then build a single time:

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

Initialization is complete only when sow check returns 0. Verify the formats and architectures that SOW read from the package headers:

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

Print the actual directories:

find ~/repo -maxdepth 6 -type d | LC_ALL=C sort

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/

One path rule is easy to miss and essential to remember: SOW always places Dists below dists/. The logical infra/rpm and infra/deb channels are therefore served from /infra/dists/rpm/ and /infra/dists/deb/, while package payloads live in /infra/pool/. Always publish or mount the complete ~/repo/infra tree, never an individual Dist.

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 8080:80 \
  --volume "$HOME/repo/infra:/usr/share/nginx/html/infra:ro" \
  nginx:alpine

Check both metadata entry points directly:

curl -fsS http://127.0.0.1:8080/infra/dists/rpm/x86_64/repodata/repomd.xml | head
curl -fsS http://127.0.0.1:8080/infra/dists/deb/Release | head

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

This isolated HTTP lab temporarily disables verification. A production service should sign RPM and APT metadata and remove both gpgcheck=0 and Trusted: yes.

Docker validates the host architecture by default. To run the complete four-cell matrix, repeat each docker run once with --platform linux/amd64 and once with --platform linux/arm64; cross-architecture execution requires Docker binfmt/QEMU support. Repository inventory and client installation are separate acceptance gates.

8. Routine maintenance: add a new version

The normal update operation is add, not removing the old package first. 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.

Removal is only for hard corrections

Do not begin a normal release with sow rm. If a bad package must be withdrawn, use sow ls -r infra -d rpm --json (or the corresponding -d deb) to find its exact SHA-256, run sow rm sha256:... -r infra -d rpm --check, inspect the plan, then run the same command without --check. rm removes only Dist Membership; conservative sow gc handles pool bytes separately. Avoid a bare package name that could remove every version and architecture.

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 --all -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 does not automatically resurrect every historical object that happens to remain in the pool. It starts accumulating versions that you explicitly promote from this point forward.

10. Promote latest into stable

SOW does not expose a dedicated promote command. The reliable current procedure is to freeze writers, export the exact source Dist Membership, and add those objects to the target Dist. Inputs come directly from infra/pool; SOW verifies and reuses each existing Package Object without repackaging it or storing a second copy.

First require clean source state and save the promotion manifests:

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

Pause writes to rpm and deb until promotion finishes, then reuse the pool objects:

cd ~/repo
(
  set -e
  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 --all -r infra -d rpm-202608 -d deb-202608

Pause stable writes during the snapshot window and first persist its exact Membership as manifests:

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 -e
  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
)

Also retain the complete verified Repository Generation so later GC treats it as a safety root:

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

retain protects a whole Repository Generation for recovery and GC; the fixed client-visible URLs are still provided by the rpm-202608 and deb-202608 Dists. SOW does not enforce Dist immutability, so never running add or rm against snapshot Dists after creation is part of the operating contract.

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

Run the final acceptance checks:

cd ~/repo
sow dist ls -r infra
sow status -r infra
sow check -r infra

The result is not a disposable demo directory. It is a real Infra Repository that can continue ingesting packages, promoting releases, and producing monthly snapshots: rpm/deb move quickly, rpm-stable/deb-stable accumulate formal history, monthly Dists provide fixed endpoints, and every view reuses the same immutable package objects.

Stop the temporary service when the lab is complete:

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

3 - Features

Plain and Managed repository generation, package pools, policy, signing, transactions, publication, and audit.

SOW has two isolated execution paths. Plain mode is a stateless rebuild of one directory; Managed mode records package membership and immutable repository generations in a workspace. Neither path silently adopts state from the other.

Capability matrix

Capability Plain Managed
RPM and DEB metadata yes yes
Mixed RPM + DEB operation one directory one Repository, separate Dists
Persistent membership and generations no yes
Per-architecture views and neutral packages no yes
exclude and version limit policy no yes
Metadata signing no RPM and DEB
RPM package signing --sign-with never, fill, always
Transaction journal and recovery rerun create workspace, Repository, publication
Queryable operation log and JSONL export no yes
Publication targets no filesystem and R2

SOW parses packages and renders metadata in-process. It does not invoke createrepo_c, dpkg-scanpackages, reprepro, or modifyrepo_c. RPM package signing is the exception: it needs the host rpm command and GPG environment because it rewrites package payloads.

Repository formats

Surface RPM/YUM DEB/APT
Package facts RPM header DEB control archive
Identity NEVRA + exact-byte SHA-256 name=version:arch + exact-byte SHA-256
Indexes primary, filelists, other, repomd.xml Packages, Packages.gz, Release
Neutral architecture noarch all
Immutable index paths checksum-named rpm-md by-hash/SHA256
Managed metadata signatures repomd.xml.asc InRelease, Release.gpg

SOW intentionally omits SQLite rpm-md, zchunk, modulemd, source-package indexes, and MD5/SHA1 DEB manifests. It builds repository files; it does not run an HTTP server or CDN.

Read by question

Question Page
What does sow create write and replace? Plain Flat Repositories
How do workspaces, repositories, Dists, and private state relate? Managed Workspaces
How does one pool feed many metadata-only views? Pool & Metadata Views
Why was a package excluded or limited? Membership Policy
Which key signs which object? Signing Model
What happens after interruption? Transactions & Recovery
How do I inspect, verify, and audit a repository? Observability & Audit

For release targets, filesystem requirements, clients, and Providers, use Platforms & Integrations.

3.1 - Plain Flat Repositories

The one-pass, overwrite-rebuild contract behind sow create, including deterministic output and the Pigsty completion marker.

sow create takes a directory that already contains .rpm and .deb files and writes flat repository indexes beside them. Plain mode has no workspace, configuration file, database, desired state, or operation journal. The package directory is the authority; every index is a disposable projection of its current contents.

That distinction is intentional. Managed repositories retain state and recover transactions. Plain repositories are cheap to recreate: if a run fails or is interrupted, run the same command again and overwrite the derived metadata.

Contract

Four rules define Plain mode:

  1. Packages are authoritative. Default create does not modify package bytes. It replaces only repodata/, Packages, and Packages.gz; --pigsty and explicit RPM signing are the documented exceptions.
  2. One package-content pass. On the normal unsigned path, every selected package is opened once, SHA-256 hashed once, and parsed during that same pass. Complete parsed RPM/DEB metadata is retained for rendering; render and validation do not reopen package payloads.
  3. One cheap final check. Immediately before publication, SOW relists the top-level package set and compares stat facts with the scan snapshot. It does not compute a second package SHA-256.
  4. Failure means rebuild. There is no Plain transaction journal, pre-image, roll-forward, or rollback. A failure may leave partially replaced derived metadata. The next sow create discards owned temporary residue and rebuilds from the package directory.

For identical input bytes, output remains deterministic and a repeat reports noop=true.

The one-pass pipeline

--jobs defaults to the logical CPU count and controls the only package-content pass:

lock directory
  -> list and sort top-level RPM/DEB candidates
  -> parallel open + SHA-256 + parse (once per package)
  -> resolve coordinates and Pigsty filtering
  -> render RPM/DEB metadata from retained parsed facts
  -> validate generated metadata only
  -> relist and compare package stat snapshots
  -> replace derived outputs; repo_complete last with --pigsty

Worker completion order never affects bytes: facts are consumed in canonical basename/index order. RPM XML is written from the parsed package object retained by the worker. DEB Packages paragraphs are written from the retained control paragraph and the SHA-256 already computed by that worker.

Output self-validation still reads generated XML, repomd.xml, Packages, and Packages.gz. Those files are small derived metadata; it does not read package bodies again.

What the final stat check proves

The final check requires:

  • the same sorted set of top-level regular .rpm/.deb basenames;
  • the same file identity/inode;
  • unchanged file type and mode;
  • unchanged size and modification time.

If any fact differs, publication is rejected with integrity exit code 5. This catches normal add, remove, replace, truncate, and rewrite races at directory-scan cost.

It is deliberately not cryptographic validation. An external writer that changes bytes in place while preserving inode, size, and mtime can evade it. Plain mode accepts that tradeoff because it targets one local cooperating writer and a rebuildable result. Use a Managed repository when hostile/concurrent mutation evidence or durable recovery is required.

Scan and output rules

  • Only regular files at the directory top level are considered; scanning never recurses and never follows symlinks.
  • Only .rpm and .deb suffixes are selected.
  • Package identity and architecture come from the RPM header or DEB control member, never the filename. RPM src and nosrc are rejected.
  • Every valid version is indexed. Two different byte streams with the same logical coordinate are rejected.
  • Default mode rejects a directory with no packages. --pigsty may converge an interrupted cleanup whose authoritative package set is now empty.

RPM input produces repodata/; DEB input produces Packages and Packages.gz:

/srv/repo/
├── pev2-1.23.0-1.noarch.rpm
├── xray_26.2.6-1_amd64.deb
├── Packages
├── Packages.gz
└── repodata/
    ├── <sha256>-primary.xml.gz
    ├── <sha256>-filelists.xml.gz
    ├── <sha256>-other.xml.gz
    └── repomd.xml

Flat locations are relative: RPM uses the bare basename and DEB uses ./<basename>. Public directories are mode 0755; generated files and repo_complete are 0644, independent of umask.

When one format disappears, SOW removes its known derived outputs. A rerun also replaces incomplete pairs such as a lone Packages left by an interruption, and removes SOW-shaped checksum RPM metadata no longer referenced by the new generation. Unknown files remain untouched.

Determinism and no-op

repomd.xml always uses revision and timestamps 0; gzip headers are fixed; ordering is canonical. A given package set therefore produces byte-identical metadata. Before publication SOW compares the staged metadata with live metadata. If package cleanup/signing is unnecessary and every output already matches, it removes the private stage without replacing public inodes and returns noop=true.

The JSON field recovered is always false because Plain create never performs journal recovery.

Publication and interruption semantics

All metadata is generated and validated in a same-directory private stage before publication starts. Individual file replacements use same-filesystem rename, and RPM publishes checksum-named metadata before repomd.xml.

This is not a multi-file transaction. A process killed during publication may leave new RPM metadata with old DEB metadata, one member of the DEB pair, or extra old checksum-named RPM metadata. That state is not evidence to reconcile; it is disposable output. The next run renders the complete current projection and overwrites/removes the residue.

The implementation creates no durable journal or recovery trash. On startup it discards SOW-owned residue in the reserved Plain staging namespace before starting a fresh scan.

The --pigsty marker gate

--pigsty additionally removes parsed package facts matching its compatibility rules (DEB i386 and Patroni 3.0.4) and writes repo_complete as <sha256><two spaces><basename>, sorted by basename. An RPM is not removed merely because its architecture is i386/i486/i586/i686.

Its publication order is:

stage + validate
  -> final stat check
  -> withdraw old repo_complete
  -> install signed RPMs, if explicitly requested
  -> install RPM and DEB metadata
  -> delete matched packages
  -> write repo_complete last

A missing marker means “not complete”; consumers must not use the directory until the marker reappears. If a run stops after withdrawing the marker, rerun sow create --pigsty. The rerun scans the packages that now exist, overwrites metadata, finishes cleanup, and writes a fresh marker. No action log is required.

Default mode refuses to run while repo_complete exists, preventing an un-gated command from leaving a stale readiness claim.

Explicit RPM signing

--sign-with authorizes package mutation and is a separate slow path. SOW signs private stage copies, validates the embedded signature and signature-neutral digest, reparses the resulting RPM, then installs the signed bytes before their metadata. These necessary signing/copy/verification reads are outside the unsigned one-pass guarantee. If signing is interrupted, rerun from the package directory; no signing transaction is replayed from a journal.

Locking and scope

sow create locks the target directory and its stable parent for one run. --timeout and --no-wait control cooperative lock acquisition. The lock prevents another cooperating SOW process from writing concurrently; it does not turn arbitrary external package mutation into a supported workload.

Use Plain for a local, single-process flat-directory build that can be regenerated. Use Managed Workspaces when desired state, audit history, atomic generation switching, or evidence-driven crash recovery is part of the requirement.

Next

3.2 - Managed Workspaces

The Workspace to Repository to Dist model, the fixed on-disk layout, how sow.yml drives everything, and the discovery and selection rules.

Managed mode is what you use when the same repository will be updated for months: packages arrive in batches, policy decides what stays, and you need to prove afterwards what changed and when. This page explains the three-tier model, the layout it produces, and how commands figure out which repository and which Dist you meant.

The three tiers

Workspace                          discovery and configuration boundary
└── Repository                     ownership boundary: pool, dists, SQLite, lock, generations
    └── Dist                       a named membership set in exactly one format
        └── Architecture View      a rendered projection — not a membership

Each tier has one job, and the boundaries are strict:

Workspace owns exactly two things: the root sow.yml and the .sow/ state directory. Nothing else at the workspace root belongs to SOW. It is the unit of discovery — commands find a workspace by walking up from a starting directory — and it is where the architecture permit list lives.

Repository is fixed at <workspace>/<name>. You cannot point it somewhere else; there is no path option. A Repository owns its pool/, dists/, SQLite database, lock, recovery state, generations, retained-generation roots, publication checkpoints, and GC evidence. Two Repositories never deduplicate against each other — the same package added to both is stored twice, on purpose, so that removing one Repository can never damage the other.

Dist is an ordinary named set of memberships in exactly one format, rpm or deb. The name is an opaque string to SOW. el9, trixie, el9-beta, customer-acme — none of these create a state machine, a promotion workflow, or a snapshot. If you want a beta channel, make a Dist called el9-beta; the meaning lives in your head and your .repo files, not in SOW.

Architecture View is what build renders. It creates no second membership. A noarch RPM has exactly one package object and exactly one membership, and gets projected into every applicable view. See Pool & Metadata Views.

One Repository can hold an RPM Dist and a DEB Dist at the same time, sharing one pool/.

The layout

Managed paths are never assembled from user input. They are derived from the resolved real workspace root, a validated name, and a fixed relative fragment — which is why symlink substitution and path escape have no surface to attack.

<workspace>/
├── sow.yml                       # the only configuration file
├── .sow/                         # private state; never serve this
│   ├── workspace.lock
│   ├── workspace-ops/            # workspace lifecycle journal
│   ├── repo-locks/<repo>.lock
│   ├── <repo>.db                 # one SQLite per repository
│   └── <repo>/
│       ├── stage/                # staging on the same filesystem
│       ├── recovery/             # atomic-move target for deletions
│       └── pending/              # durable payload for --skip
└── <repo>/                       # the servable tree
    ├── pool/                     # immutable package bytes
    └── dists/
        └── <dist>/               # architecture views rendered here

A real workspace after two dist new and two add commands:

$ find .sow | sort
.sow
.sow/pigsty
.sow/pigsty.db
.sow/pigsty.db-shm
.sow/pigsty.db-wal
.sow/pigsty/pending
.sow/pigsty/recovery
.sow/pigsty/stage
.sow/repo-locks
.sow/repo-locks/pigsty.lock
.sow/workspace-ops
.sow/workspace.lock

Everything under <repo>/ is the public delivery tree. Serve it directly, publish it with SOW, or copy the whole tree through offline staging and an atomic switch. Everything under .sow/ is private and must not be exposed; see the serving guide.

Names must match [a-z0-9][a-z0-9._-]*, and ., .., .sow, pool, dists and workspace-reserved names are rejected outright.

State database and package facts

The private SQLite database indexes Desired and Built Membership by package_sha256, so queries and builds expand a complete membership projection in bulk instead of issuing one query per package. It also keeps a rebuildable package-facts cache keyed by immutable package SHA-256. Ingest authenticates and parses a new package once; production builds load only the selected digest rows in bounded, deterministic batches and lazily rebuild a missing or corrupt row from authenticated package bytes. Unrelated or oversized facts rows are not read.

For unchanged Pool files, warm builds use device, inode, size, mtime, and ctime fingerprints to avoid rereading payload bodies. Fingerprint drift and a missing facts row share one authoritative SHA-256 pass and self-heal; sow check remains the explicit full cryptographic audit and hashes every unique physical payload once regardless of fingerprints.

The cache and its fingerprints are private implementation state; they do not change the public pool/ + dists/ layout. Never edit the database or its PRAGMA user_version by hand. A v0.3 Repository must be backed up and explicitly upgraded with sow repo migrate before ordinary 0.4 reads or writes. Fresh 0.4 Repositories already use the current schema; other maintenance is performed only when a SOW diagnostic names it.

sow.yml drives everything

There is one configuration file, parsed with a strict decoder. Unknown fields do not get ignored — they fail. So do duplicate normalized architectures, illegal names or formats, a Dist architecture that is not a subset of the workspace permit list, an invalid glob or category, and an incomplete signing block.

schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
      trixie:
        format: deb
targets:
  local:
    repository: pigsty
    provider: filesystem
    endpoint: file:///srv/mirror
    prefix: pigsty
    public_endpoint: file:///srv/mirror/pigsty/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

config show --all expands every default and normalized alias so you can see what SOW actually decided:

$ sow config show --all
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []
      trixie:
        format: deb
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

Architecture aliases are normalized once, at the parse boundary: amd64 → x86_64, arm64 → aarch64. Output is always the canonical family. The ecosystem names come back only in the rendered DEB view directory names (binary-amd64, binary-arm64).

config check is not a YAML linter. It opens each initialized Repository’s SQLite and compares the candidate configuration against the live Dists, architectures, memberships, built state, and signing availability. Removing an architecture family that memberships or built state still reference is an expected rejection (exit 6); corrupt database or protocol evidence is an integrity error (exit 5). Every write command runs the same preflight before it journals anything, so config check tells you in advance whether the next add would be refused.

$ sow config check
configuration valid: /data/ws repositories=1 dists=2

The full schema, including filesystem and r2 publication targets, is in the sow.yml reference.

Discovery: which workspace?

Managed commands look for the nearest ancestor sow.yml, in this order:

  1. If -C/--workdir DIR is given, search upward from DIR; this suppresses the current-directory candidate.
  2. Otherwise search upward from the current directory.
  3. If that finds nothing, search upward from $SOW_DIR, including after an explicit -C search fails.
  4. Still nothing: fail, with a hint about sow init, --workdir, and SOW_DIR.

The first sow.yml found wins; SOW does not keep climbing past it looking for a better one.

--workdir is not chdir. It changes only where discovery starts. A relative PATH argument in sow add is still resolved against your real current directory, which is what you want when you run sow add ./build/*.rpm -C /srv/ws.

sow create does not participate in any of this.

Selection: which repository, which Dist?

Repository selection, in order:

  1. Explicit -r/--repo NAME.
  2. The command’s starting directory is inside <workspace>/<repo>/.
  3. The workspace has exactly one Repository.
  4. Otherwise fail and list the candidates.

Dist selection, in order:

  1. One or more explicit -d/--dist NAME (repeatable).
  2. The starting directory is inside <workspace>/<repo>/dists/<dist>/.
  3. The selected Repository has exactly one Dist.
  4. Otherwise fail and list the candidates.

The important asymmetry: build, check, and status default to all Dists of the selected Repository when -d is absent, because operating on everything is the safe reading of “no filter”. add, rm, and ls require a definite Dist set, because guessing where a package should land is not safe:

$ sow ls
workspace discovery error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

That is exit 2. Every inference happens only after path type and symlink validation.

init converges, it does not reset

sow init is idempotent by design, and its rules are an architectural invariant rather than a convenience:

  • No sow.yml: create one with schema: sow/v3 and architectures: [x86_64, aarch64], plus .sow/. No Repository is created automatically.
  • Valid config already present: in stable name order, fill in whatever is not initialized yet — a missing Repository shell, a missing SQLite, an entire missing Dist. A newly created Dist immediately gets all empty views for its effective architectures.
  • Valid database state or a valid protocol pointer already present: verify only. Never overwrite, never zero a generation, never rewrite bytes.
  • An architecture was added to the config after a Dist was already initialized: init does not render the new view and does not advance the generation. The Dist stays dirty and waits for an explicit build. Removing a family that memberships or built state still use is a failure.
$ sow init .
initialized /data/ws: config_created=false repositories_initialized=0 dists_initialized=0

The point of the third and fourth rules is that init must be safe to run on a repository holding real content. It converges toward the declared configuration; it never uses “not initialized yet” as an excuse to rebuild something that already works.

Objects are processed in stable order. If an early config, Repository, or Dist commits durably and a later object then fails, the committed count is preserved, the human output reports what did commit, --json keeps the structured result, and the command exits 3 (partial success). If nothing had committed yet, it exits with the original error class instead.

Empty has a valid protocol surface

A Dist created by dist new has complete protocol entry points before you add a single package. An RPM Dist gets valid empty repodata per architecture family; a DEB Dist gets Packages, Packages.gz, by-hash entries, and a Release. If the Repository has a metadata key configured, the empty Dist is signed too.

Removing the last package therefore leaves a valid signed-if-configured empty index rather than a missing or broken protocol entry point. Actual package-manager acceptance remains a separate compatibility gate.

Protected repositories

repos:
  pigsty:
    protected: true

protected: true refuses repo rm even with -f, and returns exit 6. It does not restrict anything else: add, rm, build, and normal Dist maintenance all work. To actually delete the Repository you must edit sow.yml, pass config check, and only then remove it — which is precisely the friction the flag exists to create.

Next

3.3 - Pool & Metadata Views

One package, one owner, metadata-only APT/RPM views: canonical pool addressing, neutral packages, relocation, and the explicit reposync export.

The invariant

Within one Repository, every live Package Object has one canonical payload path under pool/. Dists and architecture views own metadata, not aliases of package bytes:

<repo>/pool/...                              canonical package payloads
<repo>/dists/<rpm-dist>/<arch>/repodata/... RPM metadata only
<repo>/dists/<deb-dist>/main/binary-*/...   APT metadata only

The same digest in another Repository or publication prefix is a separately owned object. SOW deliberately does not turn local deduplication into shared distributed ownership.

What a built Repository looks like

An RPM Dist with one x86_64 package and one noarch package has this shape:

demo/
├── pool/
│   ├── c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm
│   └── e/epel-release/epel-release-7-5.noarch.rpm
└── dists/el9/
    ├── aarch64/repodata/
    │   ├── <sha256>-primary.xml.gz
    │   ├── <sha256>-filelists.xml.gz
    │   ├── <sha256>-other.xml.gz
    │   └── repomd.xml
    └── x86_64/repodata/
        ├── <sha256>-primary.xml.gz
        ├── <sha256>-filelists.xml.gz
        ├── <sha256>-other.xml.gz
        └── repomd.xml

There is no dists/.../pool/ subtree. Content-addressed metadata from a retained live generation may coexist with the current files; repomd.xml is the pointer that selects the active set.

RPM views use computed parent-relative hrefs

rpm-md resolves each package <location href> relative to the architecture view. SOW computes the path from that view to the canonical Pool object:

<location href="../../../pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm"/>
<location href="../../../pool/e/epel-release/epel-release-7-5.noarch.rpm"/>

The depth is derived from the actual view root, not copied from a hostname or hard-coded deployment path. sow check resolves and normalizes each href, rejects escapes outside the Repository, and proves that it reaches the expected Pool object.

The complete Repository root is therefore the client and delivery boundary. Point DNF at dists/el9/x86_64/, but serve or copy the parent Repository that also contains pool/.

Why views contain metadata only

Copying each package into every architecture view would create extra object keys and uploads on storage systems without inode identity. SOW therefore gives payload ownership to the Repository Pool and lets indexes project membership. A complete copy, archive, or publication preserves that contract without depending on hardlinks.

The one-copy boundary is one Repository or one publication prefix—not a Workspace, bucket, account, or fleet. Identical packages in separate Repositories or targets retain separate owners.

Neutral packages are selected, not duplicated

An x86_64 view selects x86_64 + noarch; an aarch64 view selects aarch64 + noarch. The neutral package remains one Pool object. Each view gets its own metadata record whose location resolves to that same object.

DEB works the same way at the archive-root level: all packages appear in each applicable Packages index, while Filename: pool/... points to one canonical payload.

APT views

APT already defines Filename relative to the archive root:

Filename: pool/p/postgresql-18/libpq5_18.3-1_amd64.deb

SOW renders Packages, Packages.gz, and by-hash entries under dists/<dist>/main/binary-<arch>/. Release, InRelease, and Release.gpg are the protocol pointers and signatures. There is no per-view package alias and no per-architecture Release stub.

Ordinary clients and reposync are different contracts

The canonical layout is designed for package clients that consume the complete Repository and honor relative protocol paths. Default EL dnf reposync has a different contract: its safe-write check rejects a package location that normalizes above the per-repository download directory. This is an explicit unsupported combination; use an exported leaf for that workflow.

When a self-contained RPM leaf is required, create it outside the Repository and every configured filesystem publication root:

sow export rpm-leaf el9 x86_64 /srv/exports/el9-x86_64

The export contains its own package tree, repodata, manifest, and .sow-export.json completion marker. Copy is the default. --hardlink is an explicit same-filesystem, trusted read-only optimization. The export is not Membership, Generation, publication input, or a garbage-collection root.

Copy and publication

Canonical correctness does not depend on inode identity. Prefer a configured publication target. If another transport is required, copy the complete settled pool/ + dists/ tree with rsync, cp, or tar into an offline staging location, verify it, and switch it into service atomically. Never update the live tree file by file. Copying only one RPM architecture leaf is not supported because its metadata intentionally references the sibling root Pool.

sow changes lists each payload once under pool/, followed by metadata and pointers:

add  payload   pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm  ...
add  payload   pool/e/epel-release/epel-release-7-5.noarch.rpm                  ...
add  metadata  dists/el9/x86_64/repodata/<sha256>-primary.xml.gz               ...
add  pointer   dists/el9/x86_64/repodata/repomd.xml                            ...

There are no package-payload entries under dists/.

Next

3.4 - Membership Policy

How exclude and limit decide which packages stay in a Dist: rule fields, glob matching, version ordering, and why loosening a policy never resurrects a removed member.

Policy is the answer to “I dumped a build directory into this Dist and I do not want the debuginfo packages, and I only want the latest version of each package.” Two rules do that work, they run in a fixed order, and they run over the whole candidate set — not just the packages you happened to add this time.

The two rules and their order

candidate set  →  exclude  →  limit  →  Desired Membership

exclude drops packages that match a rule. limit then caps how many versions survive per package name and architecture. The order is fixed and never configurable, because the reverse order would let an excluded package consume a version slot on its way out.

Both rules are enforced on every add, every rm, and every build. That last one matters: editing limit or exclude in sow.yml marks the affected Dists dirty, and the next build re-applies the new policy to the existing membership. You do not have to re-add anything to make a tightened policy take effect.

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

exclude

exclude is a list of rules. Within one rule, fields are combined with AND. Within one field, multiple patterns are combined with OR. Rules are combined with OR — any rule matching excludes the package. Field order and rule order never change the result.

exclude:
  - kind: [debuginfo, debugsource, dbgsym, dbg, llvmjit]
  - name: ["test-*", "*-experimental"]
    arch: [aarch64]

That reads as: drop every debug-ish package regardless of architecture, and drop aarch64 packages whose name starts with test- or ends with -experimental.

Five fields are allowed:

Field Matches against
name the binary package name
source the normalized source name
arch x86_64, aarch64, or neutral
kind the fixed enumeration below
format rpm or deb

Patterns are case-sensitive exact strings or shell globs (*, ?, []). There is no regex, no version comparison, no negation, and no expression language. Unknown fields, empty rules, and invalid globs fail at config check rather than silently matching nothing.

kind is derived from the binary name, most specific suffix winning:

Format Name suffix kind
RPM -debuginfo debuginfo
RPM -debugsource debugsource
RPM -llvmjit llvmjit
DEB -dbgsym dbgsym
DEB -dbg dbg
any none of the above main

Classification comes from the package itself. It never depends on which directory the file came from or which host you are running on, so the same input always classifies the same way. sow show --json exposes the computed kind.

An excluded package is reported, not treated as a parse error, and it is not stored:

$ sow add pkg/blackbox_exporter-0.28.0-1.x86_64.rpm pkg/pev2-1.23.0-1.noarch.rpm -r demo -d el9
add repository=demo operation=7877233225745514469 accepted=1 failed=0 memberships=+1/-0 revision=3 generation=3 dirty=false
item input="pkg/blackbox_exporter-0.28.0-1.x86_64.rpm" status=excluded format=rpm coordinate="blackbox_exporter-0:0.28.0-1.x86_64" sha256:5759c643… dists=el9:excluded
item input="pkg/pev2-1.23.0-1.noarch.rpm" status=accepted format=rpm coordinate="pev2-0:1.23.0-1.noarch" sha256:d06d7f23… dists=el9:accepted

The command exits 0. Nothing was wrong with the excluded package — it just does not belong in this Dist. If a package is accepted by no Dist at all, no ownerless pool object is written for it.

limit

limit groups memberships by (binary name, native architecture) and keeps the newest N:

  • 0 — keep every version. This is the default.
  • positive N — keep the N newest by native version ordering.
  • negative — a configuration error.

Two details decide most real questions.

The grouping key includes architecture. limit: 1 does not mean “one version of this package in this Dist”; it means “one version per name and native architecture”. So pg_sample-1.13 for x86_64 and pg_sample-1.17 for noarch both survive in a limit: 1 Dist, because they are in different groups. Neutral (noarch/all) counts once as its own native architecture even though it renders into multiple views.

Ordering is native to the format. RPM uses EVR comparison — epoch, version, release, with the standard rpm segment rules. DEB uses Debian version comparison, where the version string already carries the epoch and revision. SOW does not invent a version scheme or compare strings lexically.

Here is limit: 1 deciding between two Debian versions of the same package and architecture:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb pkg/libpq5_18.4-1.trixie_amd64.deb -r demo -d trixielim
add repository=demo operation=2402398619981505515 accepted=1 failed=0 memberships=+1/-0 revision=4 generation=4 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=excluded format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:limited
item input="pkg/libpq5_18.4-1.trixie_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.trixie:amd64" sha256:0a7df397… dists=trixielim:accepted

Note the two levels of reporting: the item’s overall status is excluded (it did not become a member anywhere), while the per-Dist outcome is limited — telling you it lost on version, not on an exclude rule. When you have several Dists selected, each one reports its own outcome, so a package can be accepted in one and limited in another in a single command.

limit removing an older membership and adding the newer one happens inside the same operation, so the ledger shows one atomic decision rather than a delete followed by an unrelated insert.

Policy runs over the full candidate set

A common misreading is that add applies policy only to the packages on the command line. It does not. After merging your input into the target memberships, SOW evaluates exclude and then limit over the complete membership set of each selected Dist.

The practical consequence: adding version 3 to a limit: 2 Dist that already holds versions 1 and 2 removes version 1 in the same operation. You cannot smuggle a package past the version cap by adding it separately, and you never end up with N+1 members because the cap was only checked against the delta.

Loosening a policy never resurrects anything

This is the semantics people most often expect to work the other way, so it is worth showing directly. Continuing from the limit: 1 example above, remove the version that won:

$ sow rm 'deb:libpq5=3:18.4-1.trixie:amd64' -r demo -d trixielim
$ sow ls -d trixielim
repository=demo dists=trixielim dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH

The Dist is empty. The bookworm build did not come back, even though its bytes are still sitting in the pool, and even though there is now a free slot under limit: 1.

The reason is that exclude and limit remove actual Desired Memberships. SOW does not maintain a shadow list of “candidates that policy suppressed but might return later”. Pool bytes are storage, not a candidate set. Raising a limit or relaxing an exclude therefore gives you room for future additions; it does not reach back into history and guess which of the packages you once had should reappear.

To get it back, add it again — explicitly:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb -r demo -d trixielim
add repository=demo operation=590501245267266669 accepted=1 failed=0 memberships=+1/-0 revision=6 generation=6 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:accepted

Convergence is one-directional and it is stated as an invariant: tightening policy can remove members; loosening policy never restores them. That asymmetry is what makes build safe to run at any time. If it were symmetric, editing sow.yml could silently republish a package you deliberately withdrew — which is exactly the failure you do not want in a security update.

Withdrawing a package for real

sow rm removes membership, not pool bytes. The package disappears from every index, so clients can no longer resolve it through the repository. Run sow gc only after the payload becomes unreachable from every safety root, including current and retained Generations, recovery state, publication attempts, and active maintenance operations. For published targets, use sow gc TARGET; filesystem deletion is conditional and R2 is report-only. Do not manually delete canonical pool files behind SOW’s state.

Previewing a decision

sow rm -c computes the removals, the policy consequences, and the file changes a build would produce, and writes nothing:

sow rm patroni -r pgsql -d el9 -c

-c/--check takes no write lock and is mutually exclusive with --skip. Passing --timeout or --no-wait alongside it is a usage error, so that nobody mistakes a preview for something that waits on a write transaction.

Next

3.5 - Signing Model

Two independent trust chains, four key-reference forms, in-process versus external signing, and safe key changes.

There are two different questions a client can ask about a repository, and SOW answers them with two separate mechanisms. Confusing them is the most common source of “I signed it but dnf still complains”, so this page starts by pulling them apart.

Two independent trust chains

Metadata signing RPM package signing
Question answered “Is this index really from you, and unmodified?” “Is this .rpm file really from you?”
Configured by signing.rpm.metadata, signing.deb.metadata signing.rpm.packages
Produces repodata/repomd.xml.asc, InRelease, Release.gpg an OpenPGP signature embedded in the package
Changes package bytes no yes
Client setting dnf repo_gpgcheck=1, apt Signed-By dnf gpgcheck=1
Available in Plain mode no yes, via create -S KEY

They are configured separately and can be used separately. Metadata signing alone is usually the right starting point: it authenticates the whole index in one place and requires no change to the packages you received from upstream.

Managed metadata signing is controlled entirely by sow.yml. There is no CLI override, no --sign flag on build, and no way to sign one build differently from the next. That is deliberate — a repository’s signing identity is a property of the repository, not of the command that happened to update it.

Configuration

repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never              # never | fill | always
        metadata:
          key: "file:///secure/repo-signing.asc"
      deb:
        metadata:
          key: "file:///secure/repo-signing.asc"

RPM and DEB metadata keys are declared separately, so you can use the same key for both (as above) or split them. Each metadata block accepts an optional passphrase reference alongside key.

With a metadata key configured, every build produces the signature files, including for an empty Dist:

  • RPM, per architecture view: repodata/repomd.xml plus an ASCII-armored repodata/repomd.xml.asc
  • DEB, per Dist: Release plus a clearsigned InRelease and a detached armored Release.gpg

The clearsigned body of InRelease is identical to Release. Without a metadata key, neither signature file is generated at all — you get repomd.xml and Release and nothing else.

Four key-reference forms

A key reference is a URI, and the scheme decides who does the signing:

Reference Meaning Signer
keys/repo-signing.asc an ASCII-armored key at a path relative to the Workspace root in-process Go signer
file:///absolute/path.asc an ASCII-armored private key on disk in-process Go signer
env://VAR_NAME the armored key material in an environment variable in-process Go signer
agent://<fingerprint> a key held by the GPG agent in your environment external gpg

file:// and env:// need nothing installed — SOW signs metadata itself, which is why a repository with file:// metadata keys builds identically on macOS and inside a minimal container. agent:// delegates to your GPG agent, which is the right choice when the private key lives on a smartcard or must never touch a file. agent:// cannot be combined with a passphrase reference, because the agent owns that interaction.

A passphrase reference accepts a Workspace-relative path, file://, or env://; it does not accept agent://.

Nothing secret is ever persisted. Configuration, SQLite, logs, JSON output, and error text hold only the reference string, the fingerprint, and the public verifier certificate. config show --all prints references and fingerprints, never key material. If a key reference is unresolvable or unusable for signing, config check says so before you run a build.

RPM package signing

signing:
  rpm:
    packages:
      mode: fill
      key: agent://7F721C4AD40F4A9D8CA578BFAC7E4690B50CCF3B
      trusted_keys: [keys/pgdg.asc]

Three modes:

Mode Behavior
never keep the input bytes exactly as given
fill sign when the package is unsigned or its signature is not trusted; keep the bytes when an existing signature verifies against trusted_keys
always ensure the final package is validly signed by the configured key; keep the bytes if it already is, otherwise re-sign

trusted_keys automatically includes the public half of the configured key. Without a key, never is the only legal mode. fill is the default when a key is present.

Trust rings are verified independently

For a signed RPM, SOW evaluates each retained single-key ring, the current policy ring, and the combined trusted_keys ring independently. Every recognized OpenPGP signature packet must verify inside the same candidate ring, and at least one verified path must authenticate the payload. SOW never combines a packet accepted by one key with another packet accepted by a different single-key ring to invent a retained signer.

This distinction matters during a deliberate dual-signing transition: the combined trusted ring may accept the package, while neither individual retained key is allowed to claim it alone. Historical CentOS OpenPGP v3/v4 signatures remain supported. All candidate rings share one signed-byte stream, so adding trusted keys changes authorization but does not multiply package reads.

Package signing always runs rpm --addsign or rpm --resign from your environment, on a private staged copy. Your input file is never modified in place. After signing, SOW re-parses the result and requires an embedded signature, unchanged signature-neutral digest and NEVRA, and the exact configured public-key identity. The rpm and gpg executables and a matching secret key in the GPG environment used by rpm are mandatory for fill and always. A key reference identifies and verifies the signer; it does not provision the secret key into that environment.

Because signatures embed a timestamp, signing is not reproducible — the same unsigned RPM signed twice gives different bytes. Re-adding a package you already added would therefore look like a content conflict. SOW avoids that with a signature-neutral payload digest: a SHA-256 over the immutable header and payload, excluding the RPM signature header. If the logical coordinate already exists and the neutral digest matches, and the existing object satisfies the current policy, SOW reuses the existing final bytes instead of signing again. Repeated add of the same package is a stable no-op.

That reuse is narrow on purpose. never requires a full byte match, since that mode promises to preserve input bytes. If the payload digest differs, or the stored object does not satisfy the current signing policy, it is a hard conflict — add will not quietly re-sign a package in place under an existing coordinate. There is no --replace; if re-signing changes the bytes, bump the release, or plan a proper key-rotation workflow.

Key changes make Dists dirty

A Dist’s Built configuration digest covers its format, canonical architectures, limit, exclude, and the frozen signing identity. Change a key reference or a fingerprint and the digest changes, so every affected Dist becomes dirty:

$ sow status
repository=pigsty status=dirty ready_to_copy=false revision=5 generation=4 dirty_dists=el9,trixie pending=0/0 locked=false

For a metadata key change, sow build signs the indexes with the new identity and produces a new Generation.

RPM package bodies are immutable Package Objects. build does not silently re-sign an existing object under the same coordinate. If current Desired RPMs do not satisfy the new package-signing policy, build rejects the change. A staged rollover normally uses fill, makes the new key current, and keeps the old public key in trusted_keys; existing old-key packages retain their bytes while newly ingested packages use the new key. Remove the old trust only after those package coordinates have been withdrawn or replaced by new releases. Switching directly to always with a new key is valid only when every Desired RPM is already signed by that key.

The exact public certificate identity of the current built metadata is recorded per Dist, and multiple certificate versions for the same primary fingerprint can coexist — so extending an expiry or adding a subkey does not invalidate what is already published.

Plain mode

sow create /srv/repo --sign-with 6D5C5A26C36B1F73
sow create /srv/repo --sign-with 6D5C5A26C36B1F73 --overwrite

Plain mode signs RPM package bodies only; it has no metadata signing. KEY is exactly 16, 40, or 64 hexadecimal characters, without an 0x prefix; it is normalized to uppercase and passed to rpm as the _gpg_name macro. Without --overwrite, only RPMs with no parseable embedded signature are signed. With it, every retained RPM is re-signed.

--sign-with requires at least one top-level RPM retained after --pigsty cleanup. A DEB-only directory, a missing rpm binary, or an unavailable key fails before anything public changes. Signing is an explicit slow path with necessary copy, signature-verification, and final-RPM parse reads; if interrupted, rerun from the current package directory rather than replaying a Plain journal. See Plain Flat Repositories.

What the client verifies

[pigsty-el9]
name=Pigsty EL9
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch/
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.example.com/keys/repo-signing.asc
Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Signed-By: /etc/apt/keyrings/repo-signing.asc

repo_gpgcheck=1 makes dnf verify repomd.xml.asc; gpgcheck=1 makes it verify each package’s embedded signature. On the APT side, Signed-By makes apt verify InRelease. Automated checks validate generated signatures directly. Complete signed Managed dnf/APT acceptance must run with real clients in the target environment; see Platforms & Integrations for the exact evidence.

sow check verifies every declared signature and file hash as part of its normal run, so a signing misconfiguration shows up before you ship rather than on a customer’s machine.

Next

3.6 - Transactions & Recovery

Managed-mode journals, the two-level lock model, fixed commit order, and evidence-driven crash recovery.

This page explains how Managed mode prevents a live pointer from naming missing content and how it coordinates SQLite state with filesystem changes.

The invariant

On a supported local POSIX filesystem, a client following a Managed protocol pointer reads a complete old view or a complete new view, including after interruption.

Everything below exists to hold that line: metadata is fully staged and verified before anything public moves, the pointer swap is the commit decision, and every operation records enough durable evidence that the next command can finish it or undo it without guessing.

Plain sow create is intentionally outside this transaction model. Its package directory is the authority and its metadata is a disposable projection: it performs one content pass, a final stat check, and overwrite publication. An interruption is handled by running sow create again, not by replaying a journal. See Plain Flat Repositories.

Note what this does not claim. dirty does not mean a half-written index — it means the Desired state is ahead of the Built Generation while the old Built view remains complete. SOW also does not promise that two Dists flip at the same instant; it promises that each protocol view is self-consistent and that, when a write returns, every Dist included in that Operation is on its recorded Built Generation.

Two durable journals

Managed lifecycle and repository mutation use two durability substrates, each with a narrow scope:

Journal Location Covers Recovered by
Workspace file journal .sow/workspace-ops/active.json init, repo new, repo rm the next workspace-lifecycle command
Repository operation journal the repository’s SQLite dist new/rm, add, rm, build, log prune the next write command on that repository

The split is not arbitrary. Workspace lifecycle operations run when the target repository’s database does not exist yet or is about to be deleted, so they cannot use it. Repository mutations have a database available and use it. Plain has neither because its recovery unit is a fresh rebuild from packages.

The workspace journal stores the operation kind, a random 64-hex id, the repository name, and both the old and new raw sow.yml bytes with their SHA-256. The workspace lock guarantees at most one active operation. The atomic rename of sow.yml is the commit decision: if the current config still hashes to the old value, the planned journal is cleaned up and rolled back; if it hashes to the new value, SOW idempotently finishes creating the repository shell or moves the removed objects into recovery. If it matches neither, SOW refuses to guess.

The repository operation journal commits a planned operation into SQLite before any public file side effect, then records each state transition. Its payload binds the repository, the config SHA-256, the exact selected Dist set, the exact build_dists, the --skip decision, and a manifest hash covering the new object facts, the complete Desired set, the per-Dist policy outcomes, the RPM public certificate snapshot, and the target generation.

This is not SQLite’s WAL. WAL handles SQLite’s own page transactions; it cannot atomically coordinate the pool, the staging area, and dists/. The application-level journal is what spans the database and the POSIX file actions.

The operation lifecycle

planned → staged → applied → built → done
                       └──────────────→ done_dirty
   any nonterminal → recovering → built / rolled_back
   pre-apply error → failed
State What is durable
planned command, arguments, targets, and intended actions
staged new packages and metadata written to a private staging area and verified
applied Desired state and any private pending payload committed; the public tree may still be the old generation
built the complete static generation has been switched in
done / done_dirty terminal; kept as the audit record

sow log <OPERATION> shows the transitions with timestamps:

"events":[
  {"sequence":0,"state":"planned","occurred_at":"2026-08-04T04:06:32.907704Z"},
  {"sequence":1,"state":"staged","occurred_at":"2026-08-04T04:06:33.067824Z"},
  {"sequence":2,"state":"applied","occurred_at":"2026-08-04T04:06:33.253073Z"},
  {"sequence":3,"state":"built","occurred_at":"2026-08-04T04:06:34.074916Z"},
  {"sequence":4,"state":"done","occurred_at":"2026-08-04T04:06:34.077441Z"}
]

done_dirty is reachable only when you explicitly pass --skip. A default add that fails after applied returns an error, keeps the old built view serving, and leaves the operation recoverable — it does not quietly settle as dirty.

An operation that fails before applied becomes failed. This matters for a subtle case in the contract: add must record a planned operation before parsing packages, so a package with a disallowed architecture does produce an audit record. But apart from that terminal failed record, nothing is written — no package object, no membership, no pending bytes, no public tree change, no generation. You keep the audit trail without letting an invalid architecture reach any product projection.

The lock model

Locks are POSIX advisory flock on the local machine. The product contract is single-writer, local POSIX, cooperative locking — network filesystems are neither detected nor supported.

Lock File Held by
Workspace .sow/workspace.lock init, repo new/rm, dist new/rm
Repository .sow/repo-locks/<repo>.lock add, rm, build, dist new/rm, log prune
Plain directory the target directory and its stable parent sow create

When both are needed, the order is fixed: workspace first, then repository, released in reverse. The repository lock’s inode lives at a stable path and never moves with the private state directory, so removing a repository can withdraw the lock path while another process still holds an old descriptor, without a second writer forming on a new inode.

sow create locks the target directory and its stable parent. The parent lock is what stops another cooperating writer from replacing the directory by rename and then acquiring an independent lock on the substitute.

Read-only commands never take a write lock and do not accept lock flags. The ones that combine config, SQLite, and live metadata (config check, repo ls/show, dist ls/show) take shared locks for the duration of their snapshot. status is deliberately lighter: it probes the repository lock so it can report recovering or locked while a write is in flight, without blocking on it.

Two flags control waiting, on every command that takes a write lock:

Flag Behavior
-T, --timeout DUR wait up to DUR; 0 (the default) waits forever
-N, --no-wait try once and fail immediately if the lock is held

Both failure paths exit 4. Combining --no-wait with a non-zero --timeout is a usage error, exit 2.

$ sow add ./build/*.rpm -r pgsql -d el9 -N
lock unavailable

Use -N in cron jobs where a skipped run is better than a pile-up, and -T 30s in CI where a short queue is fine but a hang is not.

The commit order

Every generation is written in the same four phases, and the order is what makes the invariant hold:

payload  →  metadata  →  pointer  →  delete
  1. payload — canonical package bytes into pool/. Nothing references them yet.
  2. metadata — checksum-named RPM metadata, Packages, Packages.gz, and by-hash index copies. Still nothing points at them.
  3. pointer — the client entry points: repomd.xml (plus .asc if configured) for RPM; for Managed APT, Release (plus InRelease and Release.gpg) after every per-architecture direct and by-hash index is in place. This is the commit.
  4. delete — expired metadata from generations that have aged out.

Pending payload promotion is batched under the single writer: at most 512 objects or 1 GiB per group commit. Pool directory entries are persisted before pending names are removed, so recovery can bind a pending-only, exact dual-link, or Pool-only state back to the Operation without risking loss of both names.

Read it forward: a package always exists before an index names it, and an index always exists before a pointer names it. Read it backward: nothing is deleted until a pointer that no longer references it is durable. There is no window in which a client can follow a live pointer to a missing file.

All of this happens through a staging area on the same filesystem as the target, verified at initialization by comparing st_dev. A different mount or device is an explicit failure, never a degraded copy. Files are written, fsynced, validated by SOW’s own parser and closure validator, and only then moved in with atomic renames. Public files do not inherit your umask: repodata/ is 0755, index files and pointers are 0644.

sow changes describes the generation delta for audit and delivery planning. It is not a safe substitute for the publication protocol: use sow publish, or copy the complete tree into offline staging and switch it into service atomically. See Observability & Audit.

Crash recovery

Every Managed write command recovers before it does its own work. There is no separate repair command and no daemon watching for stale state; recovery is a precondition of mutation. If a nonterminal operation exists, the next add, rm, build, dist new/rm, or log prune completes or rolls it back first, then proceeds.

Global recovery order is fixed: workspace lifecycle first under the workspace lock, then — if that was not a repository removal — repository operations in repository-name order under each stable repository lock. A workspace operation that has already passed the repository-removal commit decision takes precedence and forbids any nested repository recovery, since recovering state inside a repository that is being deleted would be meaningless.

Recovery is evidence-driven, not optimistic. Each phase has a defined rule:

Phase reached Recovery rule
planned config still old → roll back the stage; otherwise conflicting evidence, exit 5
staged config still old → roll back; config already new → forward only
applied the new config is atomically in place; this is the commit decision, so always forward
built pointers and directories are durable; forward-commit the database rows
done database, config, and tree agree; clean up staging, repeat recovery is a no-op

This was validated by sending SIGKILL to sow add at many different moments. In every case status reported recovering, the next write command recovered that operation before executing its own, the final check passed all layers, and the public tree was never torn.

$ sow status
repository=pigsty status=recovering ready_to_copy=false ...

sow build is the one explicit forward-recovery entry point: it attempts to complete or roll back any decidable nonterminal operation before converging. If you see recovering, running sow build is the normal response.

error is reserved for the case where the journal, database, and file evidence contradict each other and no automatic choice is safe. Build refuses to overwrite; the last completed view keeps serving; you restore from backup and then run check and build. There is deliberately no repair --force, because a repair that guesses wrong is worse than a repair that refuses.

Fail-closed path safety

Managed paths are never assembled from user-supplied strings. Every create, rename, and delete follows the same sequence:

  1. resolve the workspace root to an absolute real path;
  2. reconstruct the target from a fixed relative fragment and verify the relative path contains no escape;
  3. Lstat every existing controlled component and reject symlinks and unexpected file types;
  4. delete only objects that were first atomically moved into .sow/.../recovery;
  5. before deleting, prove again that the recovery target sits inside the corresponding private state directory.

Names must match [a-z0-9][a-z0-9._-]*, and ., .., .sow, pool, dists and workspace-reserved names are rejected outright.

The same posture applies to file handles. SQLite is opened with O_NOFOLLOW and bound to a regular-file inode, re-verified by path after the connection is established; a database, WAL, shm, or rollback journal that is a symlink, a non-regular file, multiply hardlinked, or rebound during the open is rejected. log export refuses to overwrite an existing file and refuses a symlinked parent directory — which is why exporting into /tmp on macOS fails, since /tmp is a symlink there.

Journals are bounded by size: 32 MiB for the workspace, 16 MiB for a repository operation payload, and 64 MiB each for the external mutation and base manifests. An oversized journal is never truncated and never degraded — it fails outside the commit window, so a writer can never produce an operation record that a recovery reader would be unable to read back.

None of this claims to defend against a malicious process running as the same user with unlimited privileges. It defends against the realistic failure modes: crashes, races between cooperating processes, and paths that changed shape between the check and the use.

Next

3.7 - Observability & Audit

Use status, check, changes, retention, and the operation log without confusing state with proof.

Each read surface answers a different question.

Command Question Writes?
status What state is the Repository in? no
check Does the selected Repository satisfy the complete delivery contract? no
changes What physical files differ between Built Generations? no
log Which operations and dispositions were recorded? no
retain ls Which Generations are explicit local GC roots? no

status: cheap state

sow status -r local

It reports the Desired revision, Built Generation, dirty Dists, pending payload counts, lock state, and ready_to_copy. It does not hash the public tree, recover an operation, or build.

Repository state Meaning
clean Desired and Built agree
dirty Desired changed; the public tree is still the previous Built Generation
recovering a durable nonterminal operation exists
error durable evidence conflicts and automatic recovery cannot choose safely

Use status to diagnose. Do not use it as a substitute for check.

check: delivery proof

sow check -r local

In steady state, the checker reports nine ordered layers:

Layer Verifies
config strict configuration and effective Dist inputs
state SQLite schema and relational state
public-modes expected public file and directory modes
retained explicit retained Generation records and frozen metadata
package-bytes pool/pending objects against recorded SHA-256
desired-membership package identity, membership, and architecture consistency
index rendered metadata and reference closure
signature declared metadata and RPM package trust requirements
generation-manifest recorded Built manifest against the public tree

A non-terminal layout transition has a shorter diagnostic surface: config, state, public-modes, then layout-transition. The check stops there and returns not-ready until the diagnosed maintenance operation completes or is safely aborted before commit.

check writes and repairs nothing. A dirty or recovering Repository is not deliverable even if its last committed tree remains readable. Put check in the release pipeline and stop on any nonzero exit.

Every unique physical package payload receives one authoritative SHA-256 pass per check, regardless of cached fingerprints. Descriptor-bound evidence is then shared across retained records, indexes, signatures, the final Generation manifest, and changes. Signed RPM verification uses one additional shared stream for all signature packets and trust rings; it is not multiplied by the number of Dists, keys, or retained Generations.

changes: Generation difference

sow changes -r local
sow changes 0 -r local
sow changes 42 -r local --json
  • no base argument compares the current Built Generation with its predecessor;
  • base 0 describes the complete current public tree;
  • base N gives the net difference from recorded Generation N to current Built.

Rows include operation, phase, Repository-relative path, size, and SHA-256. Phases use the same vocabulary as local construction: payload, metadata, pointer, delete.

changes is a manifest/difference surface. It does not contact a destination, persist a remote checkpoint, enforce cache grace, or recover an interrupted transfer. Use sow publish TARGET for a configured live target. For an offline copy, stage a complete tree, verify it, and switch it into service atomically.

Generation retention and GC

sow retain add 42 -r local
sow retain ls -r local
sow retain rm 42 -r local
sow gc -r local

retain add verifies and freezes a Generation’s metadata and reference sets; it does not copy another package tree. The retained record is an explicit GC root. retain rm removes that root but deletes no package bytes itself.

Local sow gc deletes only payloads proven unreachable from the current state, explicit retentions, active recovery/publication state, and other recorded roots. Target GC is a separate operation: sow gc TARGET uses that Provider’s safety model.

During ordinary builds, SOW carries forward the immediately preceding RPM immutable metadata and APT by-hash objects so a reader of the previous pointer can finish. This bounded protocol window is separate from explicit retain.

Operation log

sow log -r local
sow log OPERATION -r local
sow log export operations.jsonl -r local
sow log prune 2026-01-01 -r local

The ledger records operation kind/state, timestamps, configuration/manifest identities, package dispositions, membership changes, and physical changes where applicable.

log export writes stable JSONL, refuses to overwrite an existing file, and validates its output path. log prune accepts a date or RFC 3339 timestamp and removes only eligible terminal audit records; it does not remove current state, recovery evidence, or Generation manifests still required elsewhere.

Operational pattern

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

Use status for monitoring, check for the gate, publish for target mutation, and log for later evidence.

See also

4 - Reference

Configuration schema, package references, on-disk layout, exit codes, JSON output, platforms, and integrations.

This section is the stable contract for configuration fields, package references, paths, exit codes, JSON, platforms, and integrations. CLI syntax and state transitions live in Commands; use Get Started for the operating model.

Output examples show shape; identifiers, paths, hashes, timestamps, and counts vary by workspace. The built-in sow help remains the exact syntax authority.

The complete configuration schema: workspace, repository, distribution, membership policy, signing, and publication targets.

The five ways to name a package on the command line, how ambiguity is resolved, and which forms rm, show, and where accept.

Every path SOW creates in plain and managed mode, the pool grouping rule, name constraints, and which directories must never be exposed over HTTP.

The seven exit codes and what each one means.

The sow.cli/v1 envelope, the meaning of each top-level field, and result shapes for the primary command families.

Release targets, filesystem requirements, repository-client checks, publication Providers, and the exact scope of each automated integration.

Conventions

Command examples are written without a $ prompt so you can copy a whole block. Output blocks are representative; variable values and long structures may be shortened where marked. The built-in sow help remains the exact syntax authority shipped with a binary.

Placeholders in syntax blocks are uppercase (NAME, DIR, PACKAGE); literal text is lowercase. Square brackets mark optional arguments, ... marks a repeatable one, and a vertical bar separates alternatives — the same convention sow help uses.

4.1 - sow.yml Reference

Every field of the workspace configuration file, with validation rules and a complete worked example.

sow.yml is the single configuration file of a managed workspace. It sits at the workspace root, declares which repositories and distributions exist, and holds the membership policy and signing settings that every build applies. Plain mode (sow create) never reads it.

This page lists every field the parser accepts. Anything not listed here is rejected — there are no undocumented keys and no keys reserved for future use.

How the file is read

SOW parses sow.yml with a strict decoder. Practically, that means:

  • Unknown fields are errors, not warnings. A typo like repositories: instead of repos: fails the command with exit code 2 and names the offending line.
  • Exactly one YAML document. A --- separator introducing a second document is an error.
  • Regular file only. A symlink at sow.yml, or a file larger than 16 MiB, is rejected before parsing.
  • Defaults are filled in at parse time, not written back to disk. Run sow config show --all to see the fully expanded form.

Some of the file is machine-maintained. sow init, sow repo new, sow repo rm, sow dist new, and sow dist rm rewrite sow.yml atomically as part of their transaction. Membership policy and signing are yours to edit by hand; there are no CLI flags that set them.

After any hand edit, run sow config check. It parses the file, cross-checks it against the SQLite state of every initialized repository, and resolves every signing key reference — without writing anything.

sow config check
configuration valid: /srv/repo repositories=1 dists=2

Top level

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  <name>: <repository>
targets:
  <name>: <publication-target>
Field Type Required Default Meaning
schema string yes Must be exactly sow/v3. Any other value is a configuration error.
architectures list of strings no [x86_64, aarch64] The CPU families this workspace is allowed to manage.
repos map no empty Repository name to repository configuration.
targets map no empty Publication target name to target configuration.

The configuration value must be exactly schema: sow/v3.

architectures

This is a ceiling, not a target. It declares which architectures SOW may accept at all; individual distributions inherit the whole list unless they narrow it.

Only two canonical families are supported today: x86_64 and aarch64. The DEB ecosystem names are accepted as input aliases and normalized at the parse boundary:

You may write Stored and displayed as
x86_64, amd64 x86_64
aarch64, arm64 aarch64

So architectures: [amd64, arm64] and architectures: [x86_64, aarch64] are the same configuration. Writing both aliases of one family — [amd64, x86_64] — is a duplicate and fails:

configuration error: load config "/srv/repo/sow.yml": workspace architectures: duplicate architecture "x86_64" after normalization

noarch (RPM) and all (DEB) are not architectures here. They are neutral packages, projected into every applicable view at build time, and the parser rejects them in this list. An unsupported value such as riscv64 fails immediately:

configuration error: load config "/srv/repo/sow.yml": workspace architectures: unsupported architecture "riscv64"; supported canonical families are x86_64 and aarch64

The list may be present or absent, but it may not be empty.

Repository

repos:
  pigsty:
    protected: true
    signing: { ... }
    dists: { ... }
Field Type Required Default Meaning
protected bool no false When true, sow repo rm refuses to delete this repository, even with -f.
signing map no none Package and metadata signing settings, see Signing.
dists map no empty Distribution name to distribution configuration.

protected

protected: true is a guard against deleting a whole repository by accident. It blocks exactly one thing — repository removal:

operation rejected: managed: operation rejected: repository "pigsty" is protected

That is exit code 6. Everything else keeps working normally: you can still add, rm, build, create and delete distributions. To actually remove a protected repository, edit sow.yml to set protected: false, confirm with sow config check, then run sow repo rm.

Repository names

Repository and distribution names share one grammar: they must match [a-z0-9][a-z0-9._-]* — lowercase letters, digits, dot, underscore, hyphen, starting with a letter or digit. Uppercase is rejected, because the name becomes a directory and must behave identically on case-sensitive Linux and case-insensitive macOS filesystems.

configuration error: load config "/srv/repo/sow.yml": repository name "Infra": name "Infra" must match [a-z0-9][a-z0-9._-]*

These names are reserved and rejected: ., .., .sow, pool, dists, sow.yml, workspace.lock, workspace-ops, repo-locks. Two repository names that would collide in the state directory — say db and db.db — are also rejected:

configuration error: load config "/srv/repo/sow.yml": repository names "db" and "db.db" collide at reserved state path "db.db"

See Repository Layout for why.

Dist

    dists:
      el9:
        format: rpm
        architectures: [x86_64]
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]
Field Type Required Default Meaning
format string yes rpm or deb. A distribution holds exactly one format.
architectures list of strings no inherits workspace list Narrows this distribution to a subset of the workspace families.
limit integer no 0 Maximum versions to keep per package name and architecture; 0 keeps all.
exclude list of rules no empty Rules that keep matching packages out of this distribution.

format

format is the only field sow dist new sets from the command line, and it cannot be changed afterwards — an RPM distribution never becomes a DEB one. A package whose format does not match is simply not a candidate for that distribution.

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" format must be rpm or deb, got "apk"

architectures

Omit this field and the distribution inherits the workspace list, which is what you want almost always. Declare it only to narrow: an el9 distribution that should be x86-only in an otherwise dual-architecture workspace.

The list must be a subset of the workspace list, and it may not be empty:

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" architecture "aarch64" is not allowed by workspace

Adding a family here marks the distribution dirty; the next sow build renders the new view. Removing a family that is still referenced by existing membership or by the built generation is refused by config check and by every write command.

limit

limit caps how many versions of one package survive in this distribution. The grouping key is (binary name, native architecture), so an x86_64 build and an aarch64 build of the same package are counted separately, and a noarch/all package forms its own group.

  • 0 (the default) keeps every version.
  • N > 0 keeps the newest N, comparing RPM EVR or Debian version with the native ordering rules of each ecosystem.
  • A negative value is a configuration error.
configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: limit must be zero or positive, got -1

With limit: 1, adding an older version alongside a newer one reports it as limited and does not create membership:

item input=".../libpq5_18.2-1.pgdg12+1_amd64.deb" status=excluded format=deb coordinate="libpq5=18.2-1.pgdg12+1:amd64" sha256:310611d0... dists=trixie:limited
item input=".../libpq5_18.3-1.pgdg12+1_amd64.deb" status=accepted format=deb coordinate="libpq5=18.3-1.pgdg12+1:amd64" sha256:4b526223... dists=trixie:accepted

Raising limit later does not resurrect versions that policy previously removed. The package bytes may still sit in the pool, but membership is gone; re-add the file to bring it back. See Membership Policy for the reasoning.

exclude

exclude is a list of rules. Each rule is a set of fields; within a rule the fields are ANDed, within a field the patterns are ORed, and rules are ORed with each other. A package is excluded if any single rule matches it.

exclude:
  - kind: [debuginfo, debugsource, dbgsym, dbg, llvmjit]
  - name: ["test-*", "*-experimental"]
    arch: [aarch64]

That reads: drop every debug-flavored package, and separately drop aarch64 packages whose name starts with test- or ends with -experimental.

Five fields are allowed:

Field Matched against
name Binary package name
source Normalized source name (RPM SOURCERPM, DEB Source)
arch x86_64, aarch64, or neutral
kind The classification below
format rpm or deb

kind is derived from the binary package name by its most specific suffix:

Format Name suffix kind
RPM -debuginfo debuginfo
RPM -debugsource debugsource
RPM -llvmjit llvmjit
DEB -dbgsym dbgsym
DEB -dbg dbg
any none of the above main

Patterns are case-sensitive: either an exact string or a shell glob using *, ?, and [...]. There is no regex, no version comparison, no negation, and no expression syntax. An empty rule, an empty or untrimmed pattern, a repeated pattern within one field, and an invalid glob are all configuration errors:

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: exclude rule 0 is empty
configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: exclude rule 0 field name has invalid glob "[bad": syntax error in pattern

Policy order is fixed: exclude runs first, then limit. An excluded package is reported per item and is not a failure:

item input=".../blackbox_exporter-0.28.0-1.x86_64.rpm" status=excluded format=rpm coordinate="blackbox_exporter-0:0.28.0-1.x86_64" sha256:5759c643... dists=el9:excluded

Signing

Signing settings live on the repository, not on individual distributions, and cover two independent trust chains: the packages themselves, and the repository metadata clients verify before they trust anything else.

    signing:
      rpm:
        packages:
          mode: fill
          key: env://SOW_RPM_PACKAGE_KEY
          trusted_keys: [keys/pgdg.asc]
        metadata:
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE
      deb:
        metadata:
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE

The tree is fixed. signing.rpm has packages and metadata; signing.deb has metadata only — DEB packages are never re-signed, because APT verifies the archive through Release, not through per-package signatures.

rpm.packages

Field Type Default Meaning
mode string never, or fill when key is set never, fill, or always.
key key reference none The signing key. Required unless mode is never.
trusted_keys list of key references empty Additional public keys accepted by fill.

The three modes:

  • never — input bytes are stored verbatim. Whatever signature the package arrived with (including none) is what clients get.
  • fill — sign packages that have no signature, or whose signature is not verifiable by key or one of trusted_keys. Packages that already verify keep their exact bytes.
  • always — every package must end up signed by key. Packages already signed by it keep their bytes; everything else is re-signed.

fill is the default when a key is present, because it is the mode that preserves upstream signatures. Setting mode to fill or always without a key is an error:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: rpm packages mode "fill" requires key

trusted_keys is a list of public keys whose signatures fill accepts as already-good. The public half of key is always trusted and does not need to be listed. Repeating the same reference twice is an error:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: duplicate rpm trusted key reference "keys/x.asc"

RPM package signing is the one operation that shells out: SOW calls the environment’s rpm --addsign / rpm --resign against a private staged copy, never against your input file. The private key must be available to the GPG environment that rpm uses.

rpm.metadata and deb.metadata

Field Type Default Meaning
key key reference none Key used to sign repository metadata.
passphrase passphrase reference none Passphrase for a protected private key.

Configure rpm.metadata.key and every RPM architecture view additionally publishes a detached repodata/repomd.xml.asc. Configure deb.metadata.key and every DEB distribution additionally publishes a clearsigned InRelease and a detached Release.gpg. Without a key, those files are simply not produced — repomd.xml and Release are always written.

For file:// and env:// references SOW signs in-process; no gpg binary is involved. Only agent:// requires gpg in the environment.

Changing a key reference or the fingerprint behind it marks the affected distributions dirty, because the signing identity is part of each distribution’s built configuration digest. The next sow build re-signs and produces a new generation.

Key references

A key reference is a string in one of these forms:

Form Example Notes
Path keys/repo-signing.asc ASCII-armored key file. A relative path resolves against the workspace root, not your current directory.
file://<path> file:///secure/repo-signing.asc Same as above, written explicitly. Absolute paths therefore show three slashes.
env://<VAR> env://SOW_METADATA_KEY The variable holds the armored key material itself, not a path. The name must match [A-Za-z_][A-Za-z0-9_]*.
agent://<fingerprint> agent://7F721C4AD40F...CF3B Delegates to the ambient gpg-agent. The fingerprint is 16, 40, or 64 hex digits, case-insensitive.

Any other scheme is rejected:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: deb metadata key: unsupported key reference scheme in "https://example.com/key.asc"

References are validated in two stages. Syntax is checked when the file is parsed and fails with exit code 2. Whether the reference actually resolves is checked by sow config check and by every write command, and fails with exit code 6:

operation rejected: ... deb metadata key: key reference does not resolve to a bounded regular file
operation rejected: ... deb metadata key: environment key reference SOW_METADATA_KEY is unset
operation rejected: ... deb metadata key: gpg public-key export returned no bounded key material

Secret material never leaves the reference. sow config show --all prints the reference and the resolved fingerprint, and nothing else:

    signing:
      deb:
        metadata:
          key: file:///srv/repo/keys/repo-signing.asc
          key_fingerprint: 7F721C4AD40F4A9D8CA578BFAC7E4690B50CCF3B

Private keys and passphrases are never written to sow.yml, SQLite, the operation log, JSON output, or error messages.

Passphrase references

passphrase accepts the same path, file://, and env:// forms as a key reference — but not agent://, since a passphrase is a value, not a key handle.

Two rules apply:

  • A passphrase without a key is an error. It has nothing to unlock.

    configuration error: ... repository "a" signing: deb metadata passphrase requires key
    
  • A passphrase alongside an agent:// key is an error. The agent owns the private key and handles its own prompting; a second passphrase channel would be ignored.

    configuration error: ... repository "a" signing: rpm metadata agent key uses its ambient gpg-agent and cannot accept a passphrase reference
    

Publication targets

Each target binds one configured Repository to a storage namespace. Target names use the same lower-case name grammar as repositories.

targets:
  local:
    repository: pigsty
    provider: filesystem
    endpoint: file:///srv/mirror
    prefix: pigsty
    public_endpoint: file:///srv/mirror/pigsty/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

  prod:
    repository: pigsty
    provider: r2
    endpoint: https://0123456789abcdef.r2.cloudflarestorage.com
    region: auto
    bucket: packages
    prefix: pigsty
    credential: env://SOW_R2_CREDENTIAL
    public_endpoint: https://repo.example.com/pigsty/
    max_cache_ttl: 24h0m0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true
Field Required Meaning
repository yes Existing Repository owned by this target.
provider yes filesystem or r2.
endpoint yes Canonical file:///absolute/path without trailing slash, or canonical https://host for R2.
region R2 Must be auto; forbidden for filesystem.
bucket R2 Lower-case canonical bucket name; forbidden for filesystem.
prefix yes Relative public-tree prefix; empty means the storage namespace root.
credential R2 env://NAME or file:///absolute/path; inline secrets are forbidden.
public_endpoint yes Canonical URL ending in /, used for public content/absence verification. Filesystem accepts https://, http://, or file://; R2 requires HTTP(S).
max_cache_ttl yes Canonical bounded non-negative Go duration, including explicit 0s; overflow is rejected.
authoritative_workspace yes Must be true.
single_writer yes Must be true.
exclusive_write_authority yes Must be true.

The three authority booleans are explicit safety acknowledgements, not defaults. Targets on the same storage may not have overlapping prefixes; filesystem targets may not resolve to overlapping effective paths. These rules protect conditional publication and GC from competing writers.

For provider: filesystem, configuration validation checks URL shape and overlap. At publication time the endpoint directory itself must already exist, must not be a symlink, and must resolve to one canonical real directory. SOW creates the configured prefix below that endpoint, not the endpoint itself.

The first publish durably binds the Repository, provider storage identity, and prefix. Later edits to the target name, public_endpoint, or max_cache_ttl require explicit operator confirmation with sow publish TARGET --rebind. Provider, storage endpoint, region, bucket, prefix, and Repository identity cannot be rebound; configure a new target instead. Every accepted rebind appends an immutable binding revision. Pending maintenance blocks TTL changes, and filesystem conditional-delete maintenance also blocks a public-endpoint change.

R2 credentials are private references. The environment variable value or referenced file must contain one strict JSON document, not a path or shell assignment:

{"access_key_id":"R2_ACCESS_KEY_ID","secret_access_key":"R2_SECRET_ACCESS_KEY"}

An optional temporary credential may add "session_token":"...". Unknown fields, trailing data, missing access/secret values, and documents larger than 64 KiB are rejected. config show, JSON output, and the public tree never contain the credential material.

Complete example

A workspace with two repositories: a protected production repository signing both metadata chains and filling in missing RPM signatures, and a scratch repository with no signing and no policy.

# sow.yml — workspace root configuration
schema: sow/v3

# CPU families this workspace may manage. This is a ceiling, not a target.
# amd64/arm64 are accepted as input aliases and normalized to x86_64/aarch64.
architectures: [x86_64, aarch64]

repos:

  # Production repository. Deleting it requires editing this file first.
  pigsty:
    protected: true

    signing:
      rpm:
        packages:
          # Sign RPMs that arrive unsigned or with an untrusted signature.
          # Packages already signed by a trusted key keep their exact bytes.
          mode: fill
          key: keys/package-signing.asc
          trusted_keys:
            - keys/pgdg.asc        # upstream PGDG signatures are accepted as-is
        metadata:
          # Publishes repodata/repomd.xml.asc next to every repomd.xml.
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE
      deb:
        metadata:
          # Publishes InRelease and Release.gpg next to every Release.
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE

    dists:

      # Stable EL9 channel: one version per package, no debug artifacts.
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]

      # Beta channel: same packages, every version kept for rollback.
      el9-beta:
        format: rpm
        limit: 0
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]

      # Debian trixie, x86 only, newest version wins.
      trixie:
        format: deb
        architectures: [x86_64]
        limit: 1
        exclude:
          - kind: [dbgsym, dbg]
          - name: ["*-experimental"]

  # Scratch repository: unsigned, unfiltered, deletable.
  sandbox:
    dists:
      el9:
        format: rpm
      trixie:
        format: deb

targets:
  prod:
    repository: pigsty
    provider: r2
    endpoint: https://0123456789abcdef.r2.cloudflarestorage.com
    region: auto
    bucket: packages
    prefix: pigsty
    credential: env://SOW_R2_CREDENTIAL
    public_endpoint: https://repo.example.com/pigsty/
    max_cache_ttl: 24h0m0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

Validate it before you rely on it:

sow config check
sow config show --all

What is not in sow.yml

Some things you might expect to configure are deliberately not configurable:

  • Repository paths. A repository always lives at <workspace>/<name>. There is no path: field. See Repository Layout.
  • APT components. Always main. YUM has no component concept.
  • Architecture views. Derived from architectures and the package headers, never declared per package.
  • Inline secrets. Targets accept only credential references; key and passphrase material likewise stays behind a reference.
  • Automatic retention counts. Retention is an explicit sow retain add/rm operation, not a rolling count in configuration.

See also

4.2 - Package References

The five ways to name a package on the command line, and how ambiguity is resolved.

sow rm, sow show, and sow where all take a PACKAGE argument. This page defines what you may write there. The same grammar applies to all three commands; only the handling of an ambiguous name differs.

Nothing here applies to sow add, which takes filesystem paths, not references.

The five forms

Form Example Matches
Content digest sha256:d06d7f23b9cf...b98b1229 Exactly one package object
RPM coordinate rpm:pev2-0:1.23.0-1.noarch Exactly one RPM
DEB coordinate deb:libpq5=18.3-1.pgdg12+1:amd64 Exactly one DEB
Filename pev2-1.23.0-1.noarch.rpm The package stored under that filename
Bare name pev2 Every version and architecture of that name

The first three are exact: they name one object and either hit it or fail. The last two are conveniences that may match more than one object.

You never have to construct these by hand. sow ls prints the digest and the coordinate of every package, in a form you can paste straight back into another command:

sow ls -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
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:5759c643a789631346e3ed315a696a0118f81f7cc3c65e5a4385a876983d3a18	rpm:blackbox_exporter-0:0.28.0-1.x86_64	el9	el9	pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
sha256:d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229	rpm:pev2-0:1.23.0-1.noarch	el9	el9	pool/p/pev2/pev2-1.23.0-1.noarch.rpm

Content digest

sha256:<64 lowercase hex digits>

The SHA-256 of the complete stored package bytes. This is the strongest reference SOW has: it is the object’s identity, so it can never be ambiguous.

sow where sha256:d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229
{"reference":"sha256:d06d7f23...b98b1229","locations":[{"repository":"pigsty","dists":["el9"],"built_dists":["el9"],"sha256":"d06d7f23...b98b1229","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

The digest must be complete and lowercase. There is no prefix matching and no case-folding — a short or uppercase digest is a usage rejection, not a failed lookup:

operation rejected: managed: operation rejected: sha256 reference requires 64 lowercase hexadecimal digits

Note that this digest covers the bytes as stored. If a repository re-signs RPM payloads, the digest of the object differs from the digest of the file you handed to sow add.

RPM coordinate

rpm:<name>-<epoch>:<version>-<release>.<arch>

The full NEVRA, prefixed with rpm:. Every component is required, including the epoch — 0 when the package has none.

sow where 'rpm:pev2-0:1.23.0-1.noarch'

Quote it in a shell: NEVRA contains a colon, and history expansion or path completion can otherwise mangle it.

Both the prefix and the epoch are load-bearing. Dropping either turns the string into a bare-name lookup that finds nothing:

sow where 'rpm:pev2-1.23.0-1.noarch'
operation rejected: managed: operation rejected: package reference "rpm:pev2-1.23.0-1.noarch" was not found in the selected Workspace scope

The architecture component is the one from the RPM header: x86_64, aarch64, or noarch. It is not the canonical family — a noarch package is written noarch here, even though SOW classifies it internally as neutral.

DEB coordinate

deb:<package>=<version>:<architecture>

The Debian identity triple, prefixed with deb:. The version is the complete Debian version including epoch and revision; the architecture is the ecosystem name (amd64, arm64, all), not the canonical family.

sow where 'deb:libpq5=18.3-1.pgdg12+1:amd64'

All three parts are required. deb:libpq5=18.3-1.pgdg12+1 without an architecture does not match anything.

Filename

The complete filename of the package as stored, including the extension:

sow where 'pev2-1.23.0-1.noarch.rpm'
sow where 'libpq5_18.3-1.pgdg12+1_amd64.deb'

This is the easiest form to type when you are looking at a directory listing. It is not an identity, though: filename is not what SOW uses to tell packages apart, and two distinct objects could in principle carry the same name. Prefer a coordinate or a digest in scripts.

Bare name

Just the binary package name:

sow where pev2

What this means depends on the command:

  • sow rm treats it as every version and native architecture of that name in the selected distributions. This is intentional — removing a package usually means removing all of it. Preview first with -c:

    sow rm libpq5 -d trixie -c
    {"repository":"pigsty","desired_revision":10,"built_generation":"00000000000000000010","dirty":false,"check":true,
     "removed":[{"dist":"trixie","sha256":"310611d0...","coordinate":"deb:libpq5=18.2-1.pgdg12+1:amd64","name":"libpq5"},
                {"dist":"trixie","sha256":"4b526223...","coordinate":"deb:libpq5=18.3-1.pgdg12+1:amd64","name":"libpq5"},
                {"dist":"trixie","sha256":"cadeb929...","coordinate":"deb:libpq5=18.3-1.pgdg12+1:arm64","name":"libpq5"}], ...}
    
  • sow show and sow where require it to identify exactly one object. They describe a single package, so a name matching several is refused with the candidate list:

    operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1.pgdg12+1:amd64 sha256:310611d0fea1ce82644f48d90d485c60738b21e52ab5a60e1de43875bdfef601, deb:libpq5=18.3-1.pgdg12+1:amd64 sha256:4b5262231787caf1f367f5c8705a8a03d3176c31a15e6096946d50514db128be, deb:libpq5=18.3-1.pgdg12+1:arm64 sha256:cadeb9294901ac5ae6228bd3471c444cc288d9894af0dd0730909596d9dfcefb
    

    Every candidate is printed with both its coordinate and its digest, so the fix is to copy one of them back onto the command line.

What does not work

A NEVRA without the rpm: prefix looks like a coordinate but is parsed as a bare name, and bare names do not contain epochs or architectures:

sow rm 'pev2-0:1.23.0-1.noarch' -d el9 -c
operation rejected: managed: operation rejected: package reference not found: package reference "pev2-0:1.23.0-1.noarch" matches no Desired Membership

There is also no glob, no regex, no version range, and no --all flag. If you want to select a set of packages by pattern, that is membership policy in sow.yml, not a command-line selector. The command line only ever names packages that already exist.

Scope

A reference is resolved within a scope, and the scope is set by the usual selection flags, not by the reference:

Command Default scope Narrow with
sow rm The selected distributions of the selected repository -r, -d (required when several exist)
sow show The selected repository -r, -d
sow where Every repository in the workspace -r, -d

sow where is the one that searches broadly — use it when you know a package exists somewhere but not where. sow show describes one object in one repository in full detail.

The two commands also word their misses differently, which tells you which one you ran:

# rm — the reference resolved, but nothing in the selected dists matches it
operation rejected: ... package reference "nosuchpkg" matches no Desired Membership

# show / where — nothing in the searched scope matches at all
operation rejected: ... package reference "nosuchpkg" was not found in the selected Workspace scope

Coordinates and identity

The coordinate forms above are the logical identity of a package, and SOW enforces that one coordinate maps to at most one content object inside a repository. Adding a different file under a coordinate that already exists is a hard conflict — SOW will not silently pick a winner, and there is no --replace.

Two packages that differ only in signature therefore still collide, because the coordinate is the same. If you re-sign a package for real, bump its release; if you are re-adding the identical input, SOW recognizes it and reports reused.

See also

  • sow rm — removal, preview, and batch semantics
  • sow ls, show, and where — the query commands
  • Exit Codes6 covers both “no match” and “ambiguous”

4.3 - Repository Layout

Public and private paths, including the canonical pool and metadata-only views.

SOW has one fixed Managed layout: package payloads live once under pool/, while dists/ contains metadata-only client views. The complete repository directory is the unit to serve, copy, or publish.

Plain mode

sow create writes indexes next to existing packages and leaves every unrelated file unchanged:

/srv/offline/
├── blackbox_exporter-0.28.0-1.x86_64.rpm
├── libpq5_18.3-1.pgdg12+1_amd64.deb
├── repodata/
│   ├── <sha256>-primary.xml.gz
│   ├── <sha256>-filelists.xml.gz
│   ├── <sha256>-other.xml.gz
│   └── repomd.xml
├── Packages
├── Packages.gz
└── repo_complete                         # only with --pigsty

Flat RPM metadata uses a bare package basename; flat DEB metadata uses ./<filename>. While a build is active, .sow-plain-stage-* contains private generated output. Plain has no durable journal or recovery state; a later create discards stale reserved temporary paths and rebuilds. Never serve or copy those temporary paths.

Managed workspace

<workspace>/
├── sow.yml                               # configuration; keep private
├── .sow/                                 # database, locks, stage/recovery; keep private
│   ├── workspace.lock
│   ├── workspace-ops/
│   ├── repo-locks/<repo>.lock
│   ├── <repo>.db
│   └── <repo>/
│       ├── stage/
│       ├── recovery/
│       └── pending/
└── <repo>/                               # publish this complete directory
    ├── pool/
    └── dists/

Repositories do not deduplicate across repository boundaries. .sow/ and the pending directory are private (0700). Pending payload files use their final public mode (0644), so promotion can be a namespace-only operation. Private state may contain unpublished bytes, credentials-derived state, and recovery data.

<repo>.db and its rebuildable package-facts cache are private. Neither changes the public repository layout or the sow/v3 configuration identifier.

SOW 0.4 uses internal database schema v12. Its append-only publication-target binding revisions, package facts, signer projections, and recovery evidence all remain inside <repo>.db. A v0.3 database must be backed up and upgraded with sow repo migrate; never edit PRAGMA user_version or copy the database without its matching public/private Repository state.

Canonical pool

Each package payload has one canonical path:

pool/<prefix>/<source>/<filename>

The source comes from RPM SOURCERPM or DEB Source; SOW falls back to the binary package name when the source is absent. The prefix is the first lower-case character, or the first four characters for names beginning with lib:

Source Example
postgresql-18 pool/p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb
blackbox_exporter pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
libfoo pool/libf/libfoo/libfoo1_1.0-1_amd64.deb

Pool objects are immutable. Removing distribution membership does not immediately remove their bytes; unreachable payloads are handled by sow gc only after every safety root — current, retained, recovery, publication, and any active maintenance operation — has been considered.

RPM metadata-only views

<repo>/
├── pool/
│   ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
│   └── p/pev2/pev2-1.23.0-1.noarch.rpm
└── dists/el9/
    ├── x86_64/repodata/
    │   ├── <sha256>-primary.xml.gz
    │   └── repomd.xml
    └── aarch64/repodata/
        ├── <sha256>-primary.xml.gz
        └── repomd.xml

There is no dists/<dist>/<arch>/pool/. Native packages appear only in their matching architecture metadata; noarch packages appear in every architecture view. rpm-md points back to the canonical pool:

<location href="../../../pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm"/>

The layout requires a client that honors relative rpm-md locations across the complete Repository root. Default dnf reposync rejects the parent-traversing href because its download destination escapes the view root. When a downstream tool requires a self-contained leaf, create one explicitly with:

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

The export has a local pool/ and rewritten hrefs; it is a compatibility artifact, not the canonical managed repository.

DEB views

dists/trixie/
├── Release
├── InRelease                         # when metadata signing is configured
├── Release.gpg                       # when metadata signing is configured
└── main/
    ├── binary-amd64/
    │   ├── Packages
    │   ├── Packages.gz
    │   └── by-hash/SHA256/<digest>
    └── binary-arm64/
        └── ...

Packages records refer to the same canonical pool from the archive root:

Filename: pool/p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb

Release uses SHA256 manifests and advertises Acquire-By-Hash: yes. Checksum-named rpm-md files and APT by-hash entries keep the preceding metadata reachable while the mutable pointer is replaced last.

Publication targets

Both filesystem and r2 targets receive the same logical public tree beneath their configured prefix:

<prefix>/
├── pool/
└── dists/

The published unit is always the whole repository namespace. Do not publish only one RPM architecture directory because its hrefs deliberately refer to the root pool.

Names and serving boundary

Repository and distribution names must match [a-z0-9][a-z0-9._-]*. ., .., .sow, pool, dists, sow.yml, workspace.lock, workspace-ops, and repo-locks are reserved where applicable. Case-insensitive pool-path collisions are rejected so output remains portable between Linux and default macOS filesystems.

Never expose .sow

Point your web server at <workspace>/<repo>/, not at the workspace root. The public repository needs both pool/ and dists/; the private .sow/ directory must stay hidden.

See also

4.4 - Exit Codes

The seven exit codes, what each means, and a command that reproduces it.

Every sow command exits with one of seven codes. They are stable, they are the same for every command, and they are meant to be branched on in scripts — the distinction between “this failed” and “this was correctly refused” is the whole point of having more than one nonzero code.

Code Meaning
0 Complete success, or an idempotent no-op
1 Runtime I/O, parser, renderer, or unknown internal error
2 Usage, workspace discovery, or configuration error
3 Partial success: at least one item committed, at least one failed
4 Write lock unavailable — held and --no-wait, or the timeout expired
5 Integrity or recovery error, or check judged the result not deliverable
6 Expected rejection: conflict, protected, no match, incompatible architecture

Human-readable results go to stdout; warnings and diagnostics go to stderr. Each code has a stable message prefix on stderr, and a matching class in JSON output:

Code stderr prefix JSON class
1 varies by subsystem runtime
2 usage error: / workspace discovery error: / configuration error: usage
3 ... batch partially succeeded partial
4 lock unavailable: lock
5 integrity or recovery error: integrity
6 operation rejected: rejected

sow create is the exception to the prefix column: being outside the managed layer, it prints its raw domain error on stderr (plain: scan …, plain: marker gate …) without the operation rejected: prefix. The prefix is still present in its JSON errors[].message.


0 — Success or no-op

The command did what you asked, or found there was nothing to do. Both are success: re-running sow create over an unchanged directory, or sow build on a clean repository, exits 0 and says so.

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,...,"result":{"dir":"/srv/offline","rpm":4,"deb":3,"kept":[...],"removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}

The "noop":true is how you tell a no-op from real work; the exit code does not distinguish them.

sow status is a deliberate special case. As long as the state database is readable it exits 0 in every repository state — clean, dirty, recovering, and error alike — so a script can read the structured state instead of decoding an exit code. Use sow check when you want a gate.

1 — Runtime error

Something went wrong at the I/O, parsing, or rendering layer: a directory that cannot be written, a disk that filled up, a package that cannot be read. These are environment problems, not usage problems.

chmod 500 /srv/readonly
sow create /srv/readonly
plain: create stage /srv/readonly: mkdir /srv/readonly/.sow-plain-stage-1457115008: permission denied

The staging directory is created up front precisely so this fails before anything is published. A repository that already had valid indexes still has them.

2 — Usage, discovery, or configuration

You asked for something the CLI cannot act on: an unknown flag, an ambiguous target, no workspace, or a sow.yml that does not parse. Nothing was attempted.

An unknown option:

sow status --nope
usage error: unknown option "--nope"

Mutually exclusive options:

sow build -N -T 5s
usage error: --no-wait and non-zero --timeout are mutually exclusive

An ambiguous target — the repository has two distributions and the command needs one:

sow ls
workspace discovery error: managed: workspace discovery or configuration error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

No workspace anywhere above the current directory — the message names where it searched and how to fix it:

workspace discovery error: managed: workspace discovery or configuration error: workspace not found (searched cwd="/home/vonng"); run sow init or set --workdir/SOW_DIR

A start directory that is not a real directory — a symlink, for instance, which is what /tmp is on macOS — is refused before the search even begins:

workspace discovery error: managed: workspace discovery or configuration error: discover workspace from cwd "/tmp": start is not a directory

A malformed configuration file — note that the offending line is named:

sow config check
configuration error: load config "/srv/repo/sow.yml": parse sow.yml: yaml: unmarshal errors:
  line 3: field repositories not found in type config.Config

Every syntax and schema error in sow.yml lands here.

3 — Partial success

A batch where some items were committed and some failed. This code exists so you never have to guess whether a failed sow add left the repository untouched: with 3, the valid packages are in, and the failed ones are named.

sow add ./incoming/ -d el9
add repository=pigsty operation=9162553676349401125 accepted=1 failed=1 memberships=+1/-0 revision=6 generation=6 dirty=false
item input="/incoming/broken-1.0-1.x86_64.rpm" status=failed error="invalid RPM package: parse RPM reader: unexpected EOF"
item input="/incoming/pgbouncer_fdw_18-1.4.0-1PGDG.rhel9.8.x86_64.rpm" status=reused format=rpm coordinate="pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64" sha256:45171966... dists=el9:accepted
managed: batch partially succeeded

The failed input file is left exactly where it was. With --json, the committed items are still listed in full — a nonzero exit never truncates the result:

{..., "ok":false, "result":{"accepted":1,"failed":1,"items":[...]}, "errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]}

sow init uses the same code when it commits some declared repositories or distributions and then fails on a later one.

4 — Lock unavailable

Another process holds the write lock. SOW is single-writer by design, so this is a normal, expected outcome — retry, or wait longer.

With --no-wait, the failure is immediate:

sow build -N
lock unavailable: managed: lock unavailable

With a timeout, it fails after exactly that long:

time sow build -T 2s
lock unavailable: managed: lock unavailable

real	0m2.016s

-T 0 — the default — waits indefinitely. Read-only commands never take a write lock and never return 4; sow status even reports the contention as a field:

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

5 — Integrity, recovery, or not deliverable

Two different situations share this code, and both mean “do not ship this tree yet”.

The common one is sow check on a repository whose desired state is ahead of what was built — after sow add --skip, or after a policy or signing change. Every layer passes; the repository is simply not converged:

sow rm 'rpm:pev2-0:1.23.0-1.noarch' -d el9 --skip
sow check
repository=pigsty status=dirty ready_to_copy=false revision=7 generation=6
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=69
retained	ok=true	checked=0
package-bytes	ok=true	checked=7
desired-membership	ok=true	checked=6
index	ok=true	checked=2
signature	ok=true	checked=11
generation-manifest	ok=true	checked=1
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

The fix is sow build. This is the code a deploy script should gate on — it is the difference between “the tree on disk is complete and current” and “the tree on disk is complete but stale”.

The rarer situation is genuine integrity failure: a state database, journal, and file tree that contradict each other in a way SOW cannot safely resolve on its own. It refuses to overwrite anything, and you restore from backup rather than forcing a repair. There is no --force here on purpose.

6 — Expected rejection

The command was well-formed, the environment was fine, and SOW decided the answer is no. These are policy and safety decisions, not failures.

A protected repository:

sow repo rm pigsty -f
operation rejected: managed: operation rejected: repository "pigsty" is protected

A reference that matches nothing:

sow rm nosuchpkg -d el9
operation rejected: managed: operation rejected: package reference not found: package reference "nosuchpkg" matches no Desired Membership

An ambiguous bare name, with the candidates listed so you can pick one:

sow show libpq5 -d trixie
operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1.pgdg12+1:amd64 sha256:310611d0..., deb:libpq5=18.3-1.pgdg12+1:amd64 sha256:4b526223..., deb:libpq5=18.3-1.pgdg12+1:arm64 sha256:cadeb929...

An architecture the workspace does not allow. Note that the per-item message names the detected value and tells you where to change it:

sow add ./centos-release-6-0.el6.centos.5.i686.rpm -d el9 --json
"items":[{"input":".../centos-release-6-0.el6.centos.5.i686.rpm","status":"failed",
 "error":"managed: operation rejected: unknown rpm package architecture \"i686\"; supported rpm package architectures are [x86_64, aarch64, noarch] (canonical families [x86_64, aarch64, neutral]); use a supported package or update only supported architecture families in sow.yml"}]

A directory with nothing to index:

sow create /srv/empty
plain: scan /srv/empty: no supported top-level regular RPM or DEB packages

A --pigsty completion marker guarding an existing build:

sow create /www/pigsty
plain: marker gate /www/pigsty/repo_complete: repo_complete exists; use --pigsty or remove it explicitly before rebuilding

A signing key reference that parses but does not resolve — syntax errors are 2, resolution failures are 6:

operation rejected: ... deb metadata key: key reference does not resolve to a bounded regular file
operation rejected: ... deb metadata key: environment key reference SOW_METADATA_KEY is unset

Using them in scripts

The codes are designed so a deploy pipeline can branch without parsing text:

#!/usr/bin/env bash
set -uo pipefail

sow add /incoming/*.rpm -r pigsty -d el9
case $? in
  0) ;;                                        # everything landed
  3) echo "some packages rejected, continuing with what landed" >&2 ;;
  4) echo "another writer holds the lock, retry later" >&2; exit 75 ;;
  *) echo "add failed" >&2; exit 1 ;;
esac

# Gate publication on a complete, current tree.
if ! sow check -r pigsty; then
  echo "repository not ready to publish" >&2
  exit 1
fi

sow publish mirror

Here mirror is a configured publication target for pigsty.

Two habits worth keeping: treat 4 as retryable rather than fatal, and never treat 6 as a crash — it usually means your input, not SOW, needs to change.

See also

4.5 - JSON Output

The sow.cli/v1 envelope, its fields, and result shapes for the primary command families.

Every command that produces data accepts --json. The output is a single line on stdout carrying a versioned envelope, so you can pipe it straight into jq without worrying about which command produced it.

sow status --json
{"schema":"sow.cli/v1","command":"status","ok":true,"repository":"pigsty","operation":null,
 "result":{"repository":"pigsty","status":"clean","ready_to_copy":true,"desired_revision":4,
 "built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],"pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
 "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "repository_locked":false},"errors":[]}

(Line-wrapped here for readability; the real output is one line.)

The envelope

Field Type Meaning
schema string Always sow.cli/v1. Check it before parsing anything else.
command string The command as invoked, including the subcommand: add, repo ls, config show.
ok bool true when errors is empty. Equivalent to exit code 0.
repository string or null The selected repository, or null for workspace-wide and plain-mode commands.
operation string or null The operation ID for write commands, null for read-only ones.
result object or null Command-specific payload, described below.
errors array Zero or more {code, class, message} objects.

All seven fields are always present. result is null when the command failed before meaningful work — an unknown flag, discovery failure, or invalid configuration before a Repository was selected, for example. Committed partial results and diagnostic results from check or rm --check are preserved even when the command exits nonzero.

errors

"errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]
Field Meaning
code The process exit code1 through 6.
class runtime, usage, discovery, config, partial, lock, integrity, or rejected. discovery and config are stable specializations of exit code 2.
message The same text written to stderr.

Branch on class, not on message text. Messages carry paths and package names and will change; the class will not.

A nonzero exit still returns the result

When a batch partially succeeds, ok is false and result lists everything that was committed. Never discard the payload because the exit code was nonzero — for add, that is exactly where you learn which packages landed.

Operation IDs are strings

"operation":"8632724976452398569"

Operation IDs are 64-bit values serialized as decimal strings, because they routinely exceed what an IEEE 754 double can represent exactly. In JavaScript, JSON.parse on a bare number would silently corrupt them. Keep them as strings; jq handles them correctly as-is.

Generation IDs are fixed-width strings

"built_generation":"00000000000000000004"

Generation IDs cover the full unsigned 64-bit domain and are serialized as exactly 20 zero-padded decimal digits. Treat generation, built_generation, base_generation, and Generation-valued base fields as strings. The fixed width preserves numeric order under ordinary bytewise comparison.

stdout and stderr

Results and the JSON envelope go to stdout. Warnings and error diagnostics go to stderr, in addition to appearing in the errors array. So this works:

sow check --json 2>/dev/null | jq -e '.ok'

Result shapes

create

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,
 "result":{"dir":"/srv/offline","rpm":4,"deb":3,
 "kept":["blackbox_exporter-0.28.0-1.aarch64.rpm","blackbox_exporter-0.28.0-1.x86_64.rpm",
 "libpq5_18.2-1.pgdg12+1_amd64.deb","pev2-1.23.0-1.noarch.rpm"],
 "removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}
Field Meaning
dir The absolute directory that was indexed
rpm, deb Package counts per format
kept Filenames included in the indexes, sorted
removed Packages deleted by --pigsty cleanup; empty otherwise
marker Whether repo_complete was written
marker_sha256 Digest of the marker file; present only with --pigsty
noop true when the indexes were already correct and nothing changed
recovered Reserved for schema compatibility; Plain create has no journal recovery and always reports false
signed Filenames re-signed; present only with --sign-with

init

"result":{"workspace":"/srv/repo","config_created":true,
 "repositories_initialized":0,"dists_initialized":0,"existing":[]}

On a rerun over a workspace that already exists, the counters are 0 and existing names what was found:

"result":{"workspace":"/srv/repo","config_created":false,
 "repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]}

config check and config show

"result":{"workspace":"/srv/repo","repositories":1,"dists":2}

config show returns the effective configuration itself, in the same shape as sow.yml after normalization:

"result":{"schema":"sow/v3","architectures":["x86_64","aarch64"],
 "repos":{"pigsty":{"protected":false,
 "signing":{"rpm":{"packages":{"mode":"never"}}},
 "dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null},
          "trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}}}

With --all, signing entries additionally carry key_fingerprint. Private key material and passphrases never appear.

repo ls, repo new, repo show

repo ls returns an array; repo new and repo show return one object of the same shape.

"result":{"repositories":[{"name":"pigsty","path":"/srv/repo/pigsty","protected":false,
 "dists":2,"generation":"00000000000000000004","desired_revision":4,"status":"clean","packages":7,"memberships":7,
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "config":{"protected":false,"signing":{...},"dists":{...}}}]}

packages counts distinct package objects in the pool; memberships counts distribution memberships, so a package in two distributions counts once and twice respectively.

repo rm returns only the outcome:

"result":{"name":"demo","noop":false,"removed":true}

dist ls, dist new, dist show

"result":{"dists":[{"name":"el9","format":"rpm",
 "architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},
                  {"family":"aarch64","ecosystem_arch":"aarch64"}],
 "desired_members":4,"built_members":4,"generation":"00000000000000000003","dirty":false,"status":"clean",
 "effective_config_sha256":"39913af601d10d4d4033b0c29e8d66df385f8a6eb22f45219773a7fc170d4243",
 "config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}]}

Each architecture entry carries both names: family is the canonical form used in configuration, ecosystem_arch is what appears in the published tree — identical for RPM, amd64/arm64 for DEB.

desired_members ahead of built_members, or dirty: true, means a sow build is pending. effective_config_sha256 is the digest of everything that feeds the renderer; when it changes, the distribution becomes dirty.

dist rm mirrors repo rm: {"name":"el9","noop":false,"removed":true}.

add

"result":{"operation":"320653458389425222","repository":"demo",
 "desired_revision":2,"built_generation":"00000000000000000002","dirty":false,
 "accepted":1,"failed":0,"memberships_added":1,"memberships_removed":0,
 "items":[{"input":"/incoming/pev2-1.23.0-1.noarch.rpm","status":"accepted","format":"rpm",
 "coordinate":"pev2-0:1.23.0-1.noarch",
 "sha256":"d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229",
 "dists":{"el9":"accepted"}}]}

One items entry per input path, in stable order. status is the item’s overall outcome, and dists gives the per-distribution decision:

status Meaning
accepted New package object, membership created
reused The identical object already existed; may still add membership elsewhere
excluded Kept out by policy — see dists for whether it was excluded or limited
failed Not admitted; error carries the reason

The per-distribution values are accepted, excluded, and limited. A package can be accepted by one distribution and limited by another in the same command:

"items":[{"input":".../libpq5_18.2-1.pgdg12+1_amd64.deb","status":"excluded","format":"deb",
 "coordinate":"libpq5=18.2-1.pgdg12+1:amd64","sha256":"310611d0...","dists":{"trixie":"limited"}}]

A failed item carries error instead of the package fields:

{"input":"/incoming/broken-1.0-1.x86_64.rpm","status":"failed",
 "error":"invalid RPM package: parse RPM reader: unexpected EOF"}

memberships_added and memberships_removed count both sides, because limit can evict older versions in the same operation that admits a new one.

rm

"result":{"operation":"3422380511083828695","repository":"pigsty",
 "desired_revision":5,"built_generation":"00000000000000000005","dirty":false,"check":false,
 "removed":[{"dist":"el9","sha256":"45171966...",
   "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","name":"pgbouncer_fdw_18"}],
 "dists":["el9"],
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}

check is true when the command ran with -c/--check, in which case nothing was written and changes is a forecast. Note that removed lists membership removals only — pool bytes are never deleted by rm.

build

"result":{"operation":"3701044631565986409","repository":"pigsty",
 "dists":["el9","trixie"],"desired_revision":5,"built_generation":"00000000000000000005",
 "noop":true,"dirty":false}

noop: true means the desired state already matched the built tree, so no generation was created. dists lists the distributions considered, not necessarily the ones rebuilt.

status

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "desired_revision":4,"built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],
 "pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"...","updated_at":"..."},
 "repository_locked":false}

status is one of clean, dirty, recovering, error. ready_to_copy is the field to read in a deploy script — but remember status exits 0 in every state, so test the field, not the exit code:

sow status --json | jq -e '.result.ready_to_copy' >/dev/null || exit 1

pending counts package bytes held privately after add --skip, not yet published. repository_locked reports whether another process currently holds the write lock.

check

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "built_generation":"00000000000000000004","desired_revision":4,
 "layers":[{"name":"config","ok":true,"checked":5,"issues":[]},
  {"name":"state","ok":true,"checked":1,"issues":[]},
  {"name":"public-modes","ok":true,"checked":72,"issues":[]},
  {"name":"retained","ok":true,"checked":0,"issues":[]},
  {"name":"package-bytes","ok":true,"checked":7,"issues":[]},
  {"name":"desired-membership","ok":true,"checked":7,"issues":[]},
  {"name":"index","ok":true,"checked":2,"issues":[]},
  {"name":"signature","ok":true,"checked":11,"issues":[]},
  {"name":"generation-manifest","ok":true,"checked":1,"issues":[]}]}

Steady-state checks return nine layers in this order, each with a count of what it examined and any issues found. A non-terminal layout transition instead returns config, state, public-modes, and layout-transition, then stops with a not-ready result. A dirty repository can report every steady-state layer ok: true and still fail with exit 5, because the layers verify consistency while ready_to_copy reports currency:

{...,"ok":false,"result":{"status":"dirty","ready_to_copy":false,...},
 "errors":[{"code":5,"class":"integrity",
  "message":"integrity or recovery error: managed: repository is not ready to copy: repository status is dirty"}]}

changes

"result":{"repository":"pigsty","base":"00000000000000000004","generation":"00000000000000000005","dirty":false,
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}
Field Values
op add, update, delete
phase payload, metadata, pointer, delete
path Always relative to the repository root, always /-separated
size, sha256 Present for add and update; omitted for delete

Apply the phases in that order and no client ever sees a dangling reference: package bytes first, then checksum-named metadata, then the protocol pointers (repomd.xml, Release), and only then the deletion of superseded files.

sow changes 0 yields the complete current tree as one add set — a full delivery manifest.

ls, show, where

ls returns an array of package objects; show returns exactly one under package.

"result":{"repository":"pigsty","dists":["el9"],"dirty":false,
 "packages":[{"sha256":"d06d7f23...","format":"rpm","coordinate":"pev2-0:1.23.0-1.noarch",
 "architecture":"noarch","canonical_arch":"neutral",
 "pool_path":"pool/p/pev2/pev2-1.23.0-1.noarch.rpm","filename":"pev2-1.23.0-1.noarch.rpm",
 "size":316372,"name":"pev2","source":"pev2","version":"1.23.0","epoch":"0","release":"1",
 "kind":"main","payload_sha256":"0413d629...","signature_key":"E7935D8DB9BD8B20",
 "storage":"pool","created_revision":3,"dists":["el9"],"built_dists":["el9"]}]}

The fields worth knowing:

Field Meaning
architecture As it appears in the package header: x86_64, noarch, amd64, all
canonical_arch The family SOW groups by: x86_64, aarch64, or neutral
payload_sha256 RPM only — the signature-neutral digest used to recognize re-signed copies
signature_key Key ID of the embedded signature, when the package carries one
storage pool when published, pending when added with --skip
dists / built_dists Desired membership versus what the last build published

dists longer than built_dists is another way to see that a build is pending.

where searches the whole workspace and returns locations instead of full objects:

"result":{"reference":"pev2","locations":[{"repository":"pigsty","dists":["el9"],
 "built_dists":["el9"],"sha256":"d06d7f23...","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

publish, retain, gc, export

Managed lifecycle commands use the same envelope and keep numeric Generation values as JSON strings:

Command Important result fields
publish repository, target, provider, generation, attempt, checkpoint, phase, objects, noop
publish --abort repository, target, provider, attempt, phase, objects
publish --rebind Same result as publish; the binding revision is durable private audit state, not an extra wire field
retain add / retain rm repository, record, record_identity, path
retain ls repository, generations[] with the same retained record shape
local gc operation, repository, base_generation, generation, objects, bytes, noop
target gc repository, target, provider, phase, reports, candidates, deleted_objects, deleted_bytes, retained_objects, pending_grace, completed_attempts, noop
export rpm-leaf repository, repository_id, generation, dist, arch, directory, method, signed, signer_identity, packages, files, manifest_sha256

An optional identity such as attempt, checkpoint, or local-GC operation is omitted when there is no value. R2 target GC reports candidates as retained and never reports remote deletion performed by SOW.

log

sow log returns the operation ledger, newest first:

"result":{"repository":"pigsty","operations":[{"id":"3701044631565986409","kind":"build",
 "state":"done",
 "payload_json":"{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"noop\":true,\"dists\":[\"el9\",\"trixie\"],\"build_dists\":[],\"manifest_sha256\":\"125d7266...\"}",
 "result_json":"{\"dists\":2,\"dropped_pending\":[]}",
 "created_at":"2026-08-04T04:08:08.691678Z","updated_at":"2026-08-04T04:08:08.763019Z"}]}

payload_json and result_json are strings containing nested JSON, not objects. They are stored verbatim so the audit record is byte-stable; parse them with a second pass:

sow log --json | jq -r '.result.operations[] | .payload_json | fromjson | .config_sha256'

Passing an operation ID returns the full detail — state transitions, structured build_progress events, packages, memberships, and every file action:

"result":{"repository":"pigsty","detail":{"operation":{...},"duration_ms":598,
 "events":[{"sequence":0,"state":"planned","detail_json":"{}","occurred_at":"..."},
  {"sequence":1,"state":"staged",...},{"sequence":2,"state":"applied",...},
  {"sequence":3,"state":"applied","detail_json":"{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}",...},
  {"sequence":4,"state":"built",...},{"sequence":5,"state":"done",...}],
 "packages":[{"sequence":0,"input_path":"pgbouncer_fdw_18","package_sha256":"45171966...",
  "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","disposition":"removed"}],
 "memberships":[{"sequence":0,"dist":"el9","package_sha256":"45171966...","action":"remove"}],
 "files":[{"sequence":0,"action":"add","phase":"metadata","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz","size":382,"sha256":"1a57aa2f..."}]}}

sow log prune returns what it removed:

"result":{"operation":"7140280533435786353","repository":"demo",
 "before":"2026-01-01T00:00:00+08:00","pruned":0}

Note that before echoes the absolute timestamp a bare date resolved to in your local timezone.

log export is not an envelope

sow log export writes JSON Lines — one complete operation record per line, no envelope, no --json flag. It is meant for archiving, not for scripting a single command:

sow log export - | head -1
sow log export operations.jsonl

It refuses to overwrite an existing file, and it refuses a target whose parent directory is a symlink.

A worked example

Fail a deploy unless the repository is both consistent and current, then list exactly what to copy:

#!/usr/bin/env bash
set -euo pipefail

if ! sow check -r pigsty --json 2>/dev/null | jq -e '.ok' >/dev/null; then
  echo "repository is not deliverable" >&2
  exit 1
fi

# Full manifest of the current published tree, in delivery order.
sow changes 0 -r pigsty --json \
  | jq -r '.result.changes[] | [.phase, .op, .path] | @tsv'

See also

4.6 - Platforms & Integrations

Release targets, filesystem requirements, repository clients, publication Providers, and automated integration coverage.

This page defines the environments SOW ships for, the storage semantics it requires, and the exact scope of its automated integrations. Repository generation happens inside the SOW binary; a real package manager remains the final check for a deployed repository.

Release targets

Operating system amd64 arm64 Artifact
Linux yes yes archive, RPM, DEB
macOS yes yes archive
Windows no no not supported

Release binaries use CGO_ENABLED=0 and require no language runtime. The 0.4.0 artifacts were built with Go 1.27.0; the source module requires Go 1.27.0 or newer. Archives include README.md, CHANGELOG.md, and the Apache-2.0 LICENSE; Linux packages install the same license with the binary. Use sow version to print the product version, target OS/architecture, and build toolchain.

Workspace filesystem

Managed workspaces belong on a local POSIX filesystem. Correctness depends on advisory locks, fsync, descriptor-bound path checks, and atomic same-filesystem rename. NFS and other network filesystems are not supported workspace locations.

The public <workspace>/<repo>/ tree is different: it is a closed pool/ + dists/ namespace designed for whole-root copying and publication. It does not depend on SQLite, private journals, or view-local hard-link identity. Keep the complete Repository together and never expose .sow/.

SOW rejects symlinked control paths, unsafe regular files, overlapping filesystem targets, and case-folded pool-path collisions. This keeps one Repository portable between case-sensitive Linux filesystems and the default case-insensitive macOS setup.

Automated integration matrix

Surface Environment Verified behavior
Production CLI clean room Linux CI Builds the shipping binary; creates mixed Plain RPM/DEB metadata; initializes sow/v3; creates RPM and DEB Dists; adds fixtures; runs query, build, check, changes, config, and log commands
Plain APT client Ubuntu 22.04 container Serves sow create output over HTTP; runs apt-get update, package discovery, exact-version selection, download, and install with an explicitly trusted unsigned source
RPM detached-signature transition AlmaLinux 8, 9, and 10 containers Runs real DNF clients against serial repomd.xml / repomd.xml.asc transition states and pins which combinations succeed or fail
S3-compatible transport Pinned MinIO container Exercises bucket listing, HEAD, GET, create-only/CAS writes, replay, conditional multipart upload, object metadata, retries, and prefix confinement
Release packaging Linux CI Builds four archives, two RPMs, two DEBs, and SHA256SUMS; checks package paths, Apache-2.0 metadata, and packaged license bytes

The DNF signature-transition probe is a protocol test, not a complete Managed RPM install. The APT job covers an unsigned Plain repository, not Managed metadata signing. Run the exact dnf/APT version, repository URL, access policy, and signing policy used by your deployment before promoting it.

Repository client contract

Plain RPM repositories expose repodata/ beside package files. Plain DEB repositories expose Packages and Packages.gz beside package files. They can be consumed through file:// or HTTP after the client trust policy is configured.

Managed clients consume the complete Repository root:

  • APT indexes live below dists/<dist>/main/binary-<arch>/ and refer to the root pool/. Release advertises SHA-256 by-hash indexes; configured signing adds InRelease and Release.gpg.
  • RPM metadata lives below dists/<dist>/<arch>/repodata/ and uses relative locations that point back to the root pool/. Serve the whole Repository, not one architecture directory.

Default dnf reposync rejects the canonical Managed RPM parent-relative package paths. For that workflow, generate a self-contained copy with sow export rpm-leaf. The export has local package paths and a completion manifest; it is not a second canonical Repository.

Publication Providers

Provider Contract
filesystem Publishes beneath a pre-existing safe file:// endpoint. Target GC performs exact conditional deletion only after cache grace and storage/public absence evidence.
r2 Publishes through the S3-compatible storage transport. Target GC writes exact report-only candidate records and never deletes remote objects.

Both Providers publish the same complete pool/ + dists/ namespace beneath the configured prefix. public_endpoint is part of target verification; SOW does not create an HTTP server, DNS record, bucket policy, CDN, or credentials. Validate those deployment-owned surfaces on a nonproduction prefix before enabling production publication.

Filesystem and R2 HTTP(S) endpoints share the same canonical-GET content verifier. Filesystem targets may also use descriptor-bound file:// verification. R2 public endpoints must be HTTP(S). Target name, public_endpoint, and max_cache_ttl may be changed only through explicit publish --rebind; storage identity and prefix are immutable.

Deployment gate

Before delivery, require a clean deep check and inspect the physical change plan:

sow check -r REPOSITORY
sow changes 0 -r REPOSITORY

After publication, fetch the actual repomd.xml or Release URL and run the target package manager. A local build, a Provider write, HTTP reachability, and a client install are separate checks.

See Repository Layout, Signing, and Publication & Recovery for the corresponding contracts.

5 - Commands

Complete SOW CLI syntax, options, behavior, output, and exit codes.

Each top-level command has its own page. Command groups such as config, repo, dist, retain, export, and log document their subcommands together.

The built-in sow help output is the syntax authority. These pages add selection rules, state transitions, output contracts, failure behavior, and practical examples.

Command index

sow create is the Plain-mode repository command and operates directly on a directory. sow init bootstraps Managed mode and may create sow.yml; the remaining stateful commands discover an existing Workspace. help and version are utility commands and need neither mode.

Command Mode Purpose
sow create [DIR] Plain Generate a flat RPM/DEB repository in place
sow init [DIR] Managed Initialize a Workspace and converge declared Repositories/Dists
sow config check|show Managed Validate or print the effective configuration
sow repo ls|new|show|migrate|rm Managed Manage Repositories; migrate is specialized maintenance
sow dist ls|new|show|rm Managed Manage Dists
sow add PATH... Managed Add packages to Desired Membership
sow rm PACKAGE... Managed Remove packages from Desired Membership
sow ls Managed List Desired and Built Membership
sow show PACKAGE Managed Inspect one Package Object
sow where PACKAGE Managed Locate a Package Object across the Workspace
sow status Managed Read Repository state without deep verification
sow build Managed Converge Desired state into a Built Generation
sow check Managed Verify configuration, state, bytes, views, signatures, and manifest
sow changes [BASE_GENERATION] Managed Diff Built Generations as a file delivery plan
sow publish TARGET Managed Publish a verified Generation to a configured target
sow retain add|ls|rm Managed Manage explicit retained-Generation roots
sow gc [TARGET] Managed Collect unreachable local payloads or maintain a publication target
sow export rpm-leaf Managed Build a standalone RPM compatibility leaf
sow log [OPERATION] Managed Read, export, and prune the Operation audit ledger

Global syntax

sow [OPTIONS] COMMAND [ARGS]

Running sow with no arguments prints the command list and exits 0. Use sow help COMMAND or sow help COMMAND SUBCOMMAND for built-in usage. sow version and sow --version print the binary identity.

There is no global --format, --yes, --dry-run, -q, -v, or --config. Unknown flags are usage errors.

Workspace discovery

Managed commands find the nearest ancestor containing sow.yml:

  1. Start at -C/--workdir DIR, when supplied; otherwise start at the current directory.
  2. Search upward and stop at the first sow.yml.
  3. If that search finds nothing, repeat from SOW_DIR when set. An explicit -C suppresses the current-directory candidate, but not the SOW_DIR fallback.
  4. If no Workspace is found, exit 2.

--workdir changes only the discovery start directory. It does not change the process working directory, so relative positional paths still resolve against the actual current directory.

sow create never performs Workspace discovery.

Repository selection

Commands that require one Repository select it in this order:

  1. explicit -r/--repo NAME;
  2. the Repository containing the discovery start directory;
  3. the only Repository in the Workspace;
  4. otherwise fail with exit 2 and list the candidates.

repo new and repo rm take NAME positionally and do not accept -r. sow where searches all Repositories by default; -r narrows its scope. Publication targets select their configured Repository, so publish TARGET and gc TARGET do not accept an additional Repository selection.

Dist selection

add, rm, and ls require a concrete Dist set and select it in this order:

  1. repeated -d/--dist NAME values;
  2. the Dist containing the discovery start directory;
  3. the only Dist in the selected Repository;
  4. otherwise fail with exit 2 and list the candidates.

Other commands deliberately differ:

  • build, check, and status default to all Dists when -d is absent;
  • show searches the whole selected Repository unless -d narrows it;
  • where searches all matching Dists across the Workspace unless -r/-d narrow it;
  • changes is Repository-wide and rejects -d.

Locking

Write commands other than init accept -T/--timeout DUR and -N/--no-wait. init takes the Workspace lock and waits without a CLI timeout override. Other locks are Repository-scoped except for repo new and repo rm, which also use the Workspace lock. --timeout 0 waits indefinitely; a positive timeout uses Go duration syntax such as 500ms, 30s, or 5m. --no-wait fails immediately. A positive timeout and --no-wait are mutually exclusive. Lock acquisition failure exits 4.

Read-only commands take no write lock. status still reports whether a writer holds the Repository lock.

Parallelism

-j/--jobs N is available only where SOW parses packages, hashes bytes, renders indexes, or verifies state: create, add, rm, build, check, and repo migrate. It defaults to the logical CPU count and must be at least 1.

JSON output

Commands with --json emit one versioned envelope on stdout; diagnostics remain on stderr:

{
  "schema": "sow.cli/v1",
  "command": "add",
  "ok": true,
  "repository": "demo",
  "operation": "1430722512865805553",
  "result": {},
  "errors": []
}

ok is false for every non-zero exit. A partial batch still returns its committed and failed items. See JSON Output for complete result shapes.

Without --json, every Managed command has a stable human-readable renderer. Use it for interactive work, not as a machine protocol. --json always selects the standard envelope and is the only supported interface for scripts that need structured fields.

Exit codes

Code Meaning
0 Success or idempotent no-op
1 Runtime I/O, parser, renderer, signing, or transport error
2 Usage, Workspace discovery, or configuration error
3 Partial batch success
4 Write lock unavailable
5 Integrity/recovery failure, or check ruling the tree not deliverable
6 Expected rejection: conflict, protected object, no match, or incompatible architecture

See Exit Codes for command-specific triggers.

5.1 - sow create

Generate a flat RPM/DEB repository in an ordinary directory — the Plain mode entry point.

sow create turns a directory that already contains .rpm and .deb files into a flat repository by writing indexes next to the packages. It is the whole of Plain mode: no sow.yml, no SQLite, no Workspace discovery. This page covers the one-pass scan contract, the --pigsty completion gate, and RPM signing with --sign-with.

Synopsis

sow create [DIR] [-j N] [--pigsty] [-S KEY [--overwrite]] [-T DUR | -N] [--json]

DIR defaults to the current directory.

Description

create reads the top-level regular files in DIR and renders the index formats implied by what it finds: repodata/ when RPMs are present, Packages and Packages.gz when DEBs are present, both when the directory is mixed. All architectures come from the package headers — Plain mode has no architecture flag and no permit list.

Flat metadata only ever references packages in the same directory. RPM location is the bare basename and DEB Filename is ./<basename>, so both remain relative whether the directory is exposed as a file:// source or an HTTP root.

By default create does not delete, move, rename, re-sign or rewrite a single package byte. It only replaces index paths it owns; unknown files are left alone.

Options

Flag Description Default
-j, --jobs N Parallel workers for the single package hash/parse pass logical CPU count
--pigsty Enable Pigsty compatibility cleanup and completion marker off
-S, --sign-with KEY Sign unsigned RPMs with a 16/40/64-hex GPG key ID off
--overwrite Re-sign every RPM; requires --sign-with off
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false
-h, --help Show help

Scan rules

  • Only top-level regular files ending in .rpm or .deb are considered.
  • No recursion, no symlink following, no Workspace config.
  • Every valid version enters the index. Two files claiming the same logical coordinate with different content is a hard failure.
  • A directory with no supported package is rejected in default mode. --pigsty accepts an empty authoritative set so an interrupted all-package cleanup can converge and write its marker.
sow create /srv/empty
plain: scan /srv/empty: no supported top-level regular RPM or DEB packages

Package I/O and final validation

For the normal unsigned path, each selected package has exactly one full content pass. A worker opens it, computes SHA-256 once, parses its header/control metadata, and retains the complete parsed result. RPM XML and DEB Packages are rendered from that retained result; neither rendering nor generated metadata validation reopens package payloads. --jobs parallelizes this pass while canonical result ordering keeps output bytes independent of worker scheduling.

Immediately before publication, create relists the top-level package set and compares file identity, type/mode, size, and mtime with the post-scan snapshot. This is a cheap stat check, not a second hash. A changed set or stat returns integrity error 5 before any staged output is published. Deliberately preserving inode, size, and mtime while modifying bytes is outside the local cooperative-writer contract.

Explicit RPM signing is an exception: copying, signing, signature verification, and parsing the final signed RPM necessarily add reads for packages that are modified.

Deterministic output and idempotence

The rendered metadata is byte-stable for a given input set: gzip output is deterministic, repomd.xml carries <revision>0</revision> and timestamp 0. Running create twice on an unchanged directory rewrites nothing and reports noop=true:

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=false recovered=false

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=true recovered=false

The repo_complete gate

Default mode never creates repo_complete. If the marker already exists, create refuses to write indexes rather than leaving a stale marker claiming a build that no longer matches:

sow create /srv/pigsty
plain: marker gate /srv/pigsty/repo_complete: repo_complete exists; use --pigsty or remove it explicitly before rebuilding

Either re-run with --pigsty (which withdraws and republishes the marker in its documented order) or remove the marker yourself.

–pigsty

--pigsty enables three coupled compatibility actions in one invocation. Their publication order is marker-gated, but the operation is rebuilt on retry rather than recovered from a journal:

  1. Delete DEB packages whose parsed architecture is i386. RPMs are not removed merely for carrying an i386/i486/i586/i686 architecture.
  2. Delete RPM/DEB whose binary package name is exactly patroni and whose upstream version is exactly 3.0.4. RPM compares VERSION, ignoring epoch and release; DEB strips epoch and Debian revision first. 3.0.4+foo is not a match.
  3. After all indexes render successfully, write repo_complete: the SHA-256 of every remaining top-level RPM/DEB, sorted by basename byte order, formatted <sha256><two spaces><basename>.
sow create /srv/pigsty --pigsty
created /srv/pigsty: rpm=2 deb=0 signed=0 removed=2 marker=true noop=false recovered=false
cat /srv/pigsty/repo_complete
b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead  centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm
d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab  epel-release-7-5.noarch.rpm

Cleanup only touches top-level regular package files that parsed successfully and matched a rule. Directories and unknown files are never removed by glob.

The publication order matters for callers that gate on the marker: the existing repo_complete is withdrawn before indexes switch, matched packages are deleted only after replacement metadata is installed, and the new marker is written last. A caller must treat a missing marker as incomplete.

Marker semantics

Treat a missing repo_complete as “build in progress”. That is the contract --pigsty is designed around.

Signing RPMs

-S/--sign-with KEY is the explicit authorization to modify RPM bytes. KEY is exactly 16, 40 or 64 hexadecimal characters; an 0x prefix is not accepted. SOW normalizes it to uppercase and passes it to the environment’s rpm --addsign through the _gpg_name macro. The private key, passphrase, GPG home, pinentry and any extra RPM macros come from your environment — SOW never receives, persists or echoes a secret.

  • Default: only RPMs with no parseable embedded OpenPGP signature are signed. Anything already signed keeps its bytes.
  • --overwrite requires --sign-with and switches to rpm --resign over every retained RPM.
  • Signing happens on a private same-filesystem stage copy. Each result is re-parsed to confirm the embedded signature, the signature-neutral digest and NEVRA are unchanged, and rpm-md is generated from the final complete bytes.
  • At least one top-level RPM must remain after --pigsty cleanup, and rpm must be on PATH.
sow create /srv/flat -S 0123456789ABCDEF --overwrite
plain: sign rpm epel-release-7-5.noarch.rpm: rpm executable is required for --sign-with
sow create /srv/deb-only -S 0123456789ABCDEF
plain: sign rpm: --sign-with requires at least one retained top-level RPM package
sow create /srv/flat --overwrite
usage error: --overwrite requires --sign-with
sow create /srv/flat -S ZZZZ
usage error: --sign-with must be a 16, 40, or 64 hexadecimal GPG key ID/fingerprint

Locking, staging and overwrite rebuild

create takes a write lock on the target directory and honors --timeout/--no-wait. All metadata is written to a private stage and validated before publication begins. The lock coordinates SOW writers on the local machine; arbitrary external package mutation is unsupported.

Plain create does not create a durable operation journal, rollback pre-images, or recovery trash. Publication consists of several single-file renames, so a crash may leave a partially replaced set of derived files. Re-run sow create with the intended current options: it discards reserved stale Plain temporary state and rebuilds all indexes from the packages that currently exist. recovered is always false; a rerun is a fresh overwrite build, not replay.

A flat directory has no whole-repository generation pointer, and RPM plus DEB entry points cannot be swapped in one POSIX rename. Plain therefore does not promise cross-file instantaneous atomicity. Use repo_complete as the --pigsty gate, or use Managed mode when transactional recovery is required.

Examples

Index a mixed directory:

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=false recovered=false
ls /srv/flat
centos-release-6-0.el6.centos.5.x86_64.rpm
centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm
epel-release-7-5.noarch.rpm
libpq5_18.3-1_amd64.deb
Packages
Packages.gz
repodata

Machine-readable result:

sow create /srv/flat --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,"result":{"dir":"/srv/flat","rpm":3,"deb":1,"kept":["centos-release-6-0.el6.centos.5.x86_64.rpm","centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm","epel-release-7-5.noarch.rpm","libpq5_18.3-1_amd64.deb"],"removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}

Replace a Pigsty plain build with eight workers:

sow create /www/pigsty -j 8 --pigsty

Failure envelope:

sow create /srv/empty --json
{"schema":"sow.cli/v1","command":"create","ok":false,"repository":null,"operation":null,"result":{"dir":"","rpm":0,"deb":0,"kept":null,"removed":null,"marker":false,"noop":false,"recovered":false},"errors":[{"code":6,"class":"rejected","message":"operation rejected: plain: scan /srv/empty: no supported top-level regular RPM or DEB packages"}]}

Exit codes

Code Trigger
0 Indexes written, or unchanged input produced a no-op
1 Directory unreadable or missing, package parse failure, renderer failure, signing tool failure
2 Usage error — --overwrite without --sign-with, malformed key, --no-wait with a non-zero --timeout
4 Directory write lock held and --no-wait given or --timeout expired
5 Input set/stat changed before publication, or a controlled output path failed an integrity check
6 No supported package found, repo_complete gate hit, --sign-with on a DEB-only directory, coordinate conflict

See also

5.2 - sow init

Create a Workspace, and converge whatever Repositories and Dists sow.yml already declares.

sow init creates the root sow.yml and the private .sow/ state directory that make a directory a Workspace. It is also the convergence command for a config you wrote by hand: if sow.yml already declares Repositories and Dists, init materializes the ones that don’t exist yet and leaves the finished ones alone.

Synopsis

sow init [DIR] [--json]

DIR defaults to the current directory. init takes no -C/--workdir — the positional argument already names the target unambiguously.

Description

A fresh init writes a minimal config and the private state directory:

sow init .
initialized /srv/repo: config_created=true repositories_initialized=0 dists_initialized=0
cat sow.yml
schema: sow/v3
architectures:
  - x86_64
  - aarch64
ls -a /srv/repo
.  ..  .sow  sow.yml

.sow/ holds workspace.lock, the workspace-ops/ durable file journal used by Workspace lifecycle commands, repo-locks/, and later one SQLite database per Repository. It is mode 0700 and must never be served over HTTP.

Options

Flag Description Default
--json Emit the versioned JSON envelope false
-h, --help Show help

Idempotence rules

init is designed to be safe to run repeatedly, in a provisioning script or by hand:

  1. It writes schema: sow/v3 and the default architectures: [x86_64, aarch64] when creating a new config.

  2. It never creates a Repository on its own. Use sow repo new, or declare one in sow.yml first.

  3. It never overwrites an existing sow.yml. A repeat run reports the current state and lists what it found:

    sow init .
    initialized /srv/repo: config_created=false repositories_initialized=0 dists_initialized=0
    
  4. A non-empty directory can be initialized, but the run fails if an existing file collides with a SOW reserved path.

Converging a declared configuration

If sow.yml already describes Repositories and Dists, init creates the missing directory trees, SQLite databases and empty indexes for them. Already-initialized objects are skipped, so the counters tell you exactly what this run did.

schema: sow/v3
architectures: [x86_64, aarch64]

repos:
  pgsql:
    dists:
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource]
      trixie:
        format: deb
  infra:
    protected: true
    dists:
      el9:
        format: rpm
sow init .
initialized /srv/repo: config_created=false repositories_initialized=2 dists_initialized=3
sow repo ls
NAME	PROTECTED	DISTS	GENERATION	STATUS	PACKAGES	MEMBERSHIPS
infra	true	1	1	clean	0	0
pgsql	false	2	2	clean	0	0

Every Dist created this way has a protocol-complete empty surface: an RPM Dist gets an empty repodata/ per architecture view, and a DEB Dist gets empty Packages/Packages.gz with by-hash plus a Release.

Running it a second time changes nothing:

sow init . --json
{"schema":"sow.cli/v1","command":"init","ok":true,"repository":null,"operation":null,"result":{"workspace":"/srv/repo","config_created":false,"repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]},"errors":[]}

Locking and recovery

Workspace lifecycle commands — init, repo new, repo rm — run before the target Repository database exists or after it is deleted, so they use .sow/workspace.lock plus the durable file journal in .sow/workspace-ops/ rather than a SQLite Operation Journal. An interrupted init is completed or rolled back by the next Workspace lifecycle command.

Examples

Bootstrap a Workspace and add Repositories by hand:

mkdir -p /srv/repo && cd /srv/repo
sow init
sow repo new infra
sow repo new pgsql
sow dist new el9 --format rpm -r pgsql
sow dist new trixie --format deb -r pgsql

Initialize a directory other than the current one:

sow init /srv/repo

Provision from a config file under version control:

install -m 0644 sow.yml /srv/repo/sow.yml
sow init /srv/repo
sow config check -C /srv/repo

Exit codes

Code Trigger
0 Workspace created, or already converged (no-op)
1 Runtime I/O error writing the config or state directory
2 Usage error, or an existing sow.yml that fails to parse or validate
3 Partial success — some declared Repositories/Dists were committed and at least one failed
5 The Workspace journal could not be recovered to a terminal state
6 An existing file collides with a SOW reserved path

See also

5.3 - sow config

Validate sow.yml without touching anything, and print the effective configuration for any scope.

sow config has two read-only subcommands. config check is the full preflight over sow.yml — run it after every hand edit and in CI. config show prints the configuration SOW actually computed, which is where you confirm that defaults, inherited architectures and normalized aliases resolved the way you expected.

Neither subcommand creates directories, touches a database, or corrects your file.

Synopsis

sow config check [-C DIR] [--json]
sow config show [--all] [-C DIR] [-r NAME] [-d NAME]... [--json]

sow help config lists both.

sow config check

Parses and validates the complete sow.yml: schema version, names, path collisions, the architecture permit list, Dist formats, membership policy and signing key references. It reports the Workspace it resolved and how much it validated.

sow config check
configuration valid: /srv/repo repositories=1 dists=2
sow config check --json
{"schema":"sow.cli/v1","command":"config check","ok":true,"repository":null,"operation":null,"result":{"workspace":"/srv/repo","repositories":1,"dists":2},"errors":[]}

Options

Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
--json Emit the versioned JSON envelope false
-h, --help Show help

Strict field rejection

Unknown keys are errors, not warnings. A typo can’t silently disable a policy:

sow config check
configuration error: load config "/srv/repo/sow.yml": parse sow.yml: yaml: unmarshal errors:
  line 8: field bogus_field not found in type config.DistConfig

The schema version is pinned:

sow config check
configuration error: load config "/srv/repo/sow.yml": config schema must be "sow/v3", got "invalid"

The only valid value is schema: sow/v3. Do not change the schema string as a way to bypass a validation error.

check also verifies that every declared signing key reference resolves and is usable for signing — without ever printing key material. If you remove an architecture from the permit list while a Dist config, Membership or Built Generation still uses it, config check rejects the configuration.

sow config show

Prints the effective configuration as YAML for the currently selected scope.

sow config show
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []
      trixie:
        format: deb
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

Compare that with the file on disk, which carries only what you wrote:

cat sow.yml
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
      trixie:
        format: deb

show filled in protected: false, the inherited per-Dist architectures, limit: 0 and an empty exclude list. Architectures are always printed as canonical families (x86_64, aarch64), never as ecosystem aliases — amd64 and arm64 are the DEB spellings of the same two families.

Options

Flag Description Default
--all Expand defaults and normalized architectures across the whole Workspace off
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-d, --dist NAME Select a distribution; repeatable selection rules
--json Emit the versioned JSON envelope false
-h, --help Show help

Scope projection with -r and -d

-r and -d narrow the output to the selected objects. This is the fast way to answer “what policy is actually in effect for this one Dist”:

sow config show -r pigsty -d el9
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

--all goes the other way: it expands the entire Workspace regardless of where you are standing.

Secrets are never printed

Key material and passphrases never appear in config show, in JSON, in the operation log, or in error text. Only the reference (file://…, env://…, agent://…) and fingerprint are shown.

Examples

Validate before a build in CI:

sow config check -C /srv/repo || exit 1
sow build -r pgsql

Diff effective policy across two Dists:

sow config show -r pgsql -d el9 > /tmp/el9.yml
sow config show -r pgsql -d el9-beta > /tmp/beta.yml
diff -u /tmp/el9.yml /tmp/beta.yml

Exit codes

Code Trigger
0 Configuration valid, or output printed
1 Runtime I/O error reading the config file
2 Usage error, Workspace not found, unknown field, wrong schema, or any validation failure
6 A named Repository or Dist does not exist

config check reports validation failures as exit 2, not 6: an invalid sow.yml is a configuration error, not a rejected operation.

See also

5.4 - sow repo

List, create, inspect and remove Repositories — the lock, transaction and Generation boundary.

A Repository owns one pool/, one dists/, one SQLite database and one private state directory. It is the boundary of locking, transaction recovery, Generation numbering and Changesets — nothing is deduplicated across Repositories and no cross-Repository commit is atomic. sow repo manages that boundary.

Synopsis

sow repo ls [-C DIR] [--json]
sow repo new NAME [-C DIR] [-T DUR | -N] [--json]
sow repo show [NAME] [-C DIR] [-r NAME] [--json]
sow repo migrate [NAME] [--abort] [-j N] [-C DIR] [-r NAME] [-T DUR | -N] [--json]
sow repo rm NAME [-f|--force] [-C DIR] [-T DUR | -N] [--json]

Naming

A Repository name must match [a-z0-9][a-z0-9._-]* and may not be ., .., .sow, pool, dists, or collide with a Workspace reserved file.

sow repo new .sow
operation rejected: managed: operation rejected: name ".sow" must match [a-z0-9][a-z0-9._-]*

You cannot choose the path. A Repository always lives at <workspace>/<NAME>/.

sow repo ls

Read-only listing of every Repository in the Workspace.

sow repo ls
NAME	PROTECTED	DISTS	GENERATION	STATUS	PACKAGES	MEMBERSHIPS
infra	true	1	1	clean	0	0
pgsql	false	2	2	clean	0	0
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
--json Emit the versioned JSON envelope false

STATUS is one of clean, dirty, recovering or error. See Transactions & Recovery for what each one implies for clients.

sow repo new

Atomically updates sow.yml, then creates <workspace>/<NAME>/{pool,dists}, the SQLite database and the private state directory. A new Repository is Generation 0 and clean.

sow repo new pigsty
created pigsty: path=/srv/repo/pigsty protected=false dists=0 generation=0 status=clean packages=0 memberships=0
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

repo new takes the Workspace lock, not a Repository lock — the Repository database does not exist yet. It does not accept -r; the positional argument already names the target.

Running it again on an existing Repository is a converging no-op that reports the current state, so it is safe in a provisioning script.

sow repo show

Read-only detail for one Repository. With NAME omitted, the usual Repository selection rules apply.

sow repo show pigsty
repository pigsty:
  path: /srv/repo/pigsty
  protected: false
  dists: 2
  generation: 6
  desired_revision: 6
  status: clean
  packages: 5
  memberships: 8
  config: {"protected":false,"signing":{"rpm":{"packages":{"mode":"never"}}},"dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":1,"exclude":[{"kind":["debuginfo","debugsource"]}]},"trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}
  dirty_reasons: []
  recent_operation: id=4142220455201181493 kind=add state=done error_class= created_at=2026-08-04T04:09:24.995538Z updated_at=2026-08-04T04:09:25.332772Z
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select the repository when NAME is omitted selection rules
--json Emit the versioned JSON envelope false

If you give both NAME and -r, they must agree; disagreement fails before any state is read:

sow repo show demo -r empty
operation rejected: repo show NAME "demo" and --repo "empty" select different repositories

sow repo migrate

This is a specialized maintenance command, not part of a fresh 0.4 Managed workflow. A Repository created by SOW 0.4 already has the current single-payload layout and schema.

After upgrading an existing v0.3 Workspace, however, migration is mandatory: stop all Workspace writers, take a backup, and run the command once for every configured Repository before ordinary reads or writes.

cp -a /srv/sow /srv/sow.backup-before-0.4.0
sow repo migrate pigsty -C /srv/sow
sow repo migrate pgsql -C /srv/sow

The 0.4 transition installs schema v11 and v12. It recomputes Repository status from every Dist, repairs publication and Generation signer projections without guessing a missing historical signer, removes stale abandoned-object evidence, and backfills revision 1 of the append-only publication-target binding ledger. An unrecorded v0.3 historical signer remains explicitly unverified; it cannot reach the current head or become a retained trust assertion.

The completed schema transition is one-way. Do not reopen the database with SOW 0.3, and do not edit PRAGMA user_version. --abort applies only to a diagnosed pre-commit layout-maintenance attempt; it does not undo a completed schema migration. Outside an upgrade or an explicit SOW diagnostic, do not run migration speculatively.

Flag Description Default
-j, --jobs N Parallel verification/render workers logical CPUs
--abort Abandon a maintenance attempt before its commit decision false
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select the repository when NAME is omitted selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

sow repo rm

Removes a Repository: its sow.yml entry, database, pool/, dists/ and private state. It never follows symlinks and never steps outside the fixed Repository path.

Without -f, only an empty Repository — no Dists, no Memberships, no Package Objects — can be removed:

sow repo rm infra
removed repository infra
sow repo rm pgsql
operation rejected: managed: operation rejected: repository "pgsql" is not empty; use --force
sow repo rm pgsql -f
removed repository pgsql
Flag Description Default
-f, --force Remove a non-empty unprotected repository false
-C, --workdir DIR Workspace discovery start directory current directory
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

What -f actually downgrades

-f only relaxes the emptiness precondition. It does not bypass path safety, symlink refusal, or the protected gate.

protected

protected: true in sow.yml blocks Repository deletion outright, -f included:

sow repo rm alpha -f
operation rejected: managed: operation rejected: repository "alpha" is protected

To remove a protected Repository you must edit sow.yml, pass sow config check, and try again. There is no --yes and no temporary override.

protected scopes to Repository deletion only. Package-level work on a protected Repository is unaffected — add, rm, build, and even dist rm, all continue to work:

sow dist rm el9 -r alpha -f
removed dist el9 from alpha

Examples

Create the Repositories for a two-tier layout:

sow repo new infra
sow repo new pgsql

Fail fast in a cron job rather than queue behind another writer:

sow repo new nightly -N || echo "another writer holds the workspace lock"

Audit every Repository in one line each:

sow repo ls --json | jq -r '.result.repositories[] | "\(.name)\t\(.status)\tgen=\(.generation)"'

Exit codes

Code Trigger
0 Listed, created, shown, migrated, abandoned a pre-commit transition, or removed; or repo new converged an existing Repository
1 Runtime I/O error creating or removing the tree
2 Usage error, Workspace not found, or an ambiguous Repository selection
4 Workspace lock held and --no-wait given or --timeout expired
5 Integrity or recovery error in the Workspace journal
6 Invalid name, unknown Repository, non-empty without -f, protected, or NAME conflicting with -r

See also

5.5 - sow dist

List, create, inspect and remove Dists — the named single-format member set clients actually consume.

A Dist is a named set of packages in exactly one format (rpm or deb) inside one Repository. It is what a client points at. A Repository can hold RPM and DEB Dists side by side; they share one pool/ but render into completely separate dists/ subtrees.

Synopsis

sow dist ls [-C DIR] [-r NAME] [--json]
sow dist new NAME --format rpm|deb [-C DIR] [-r NAME] [-T DUR | -N] [--json]
sow dist show NAME [-C DIR] [-r NAME] [--json]
sow dist rm NAME [-f|--force] [-C DIR] [-r NAME] [-T DUR | -N] [--json]

Naming

Dist names follow the same rule as Repository names: [a-z0-9][a-z0-9._-]*, excluding ., .., .sow, pool and dists.

To SOW the name is an opaque string. el9, trixie, el9-beta, customer-acme, 2026-07-31 are all just names — beta channels, per-customer views and snapshots are naming conventions you impose, not features SOW models.

sow dist ls

Read-only flat listing of the selected Repository’s Dists.

sow dist ls -r pigsty
NAME	FORMAT	ARCHITECTURES	DESIRED	BUILT	GENERATION	DIRTY	DIRTY_REASONS
el9	rpm	x86_64,aarch64	0	0	1	false	[]
trixie	deb	x86_64,aarch64	0	0	2	false	[]

DESIRED and BUILT are membership counts. When they diverge, DIRTY_REASONS says why:

sow dist ls -r demo
NAME	FORMAT	ARCHITECTURES	DESIRED	BUILT	GENERATION	DIRTY	DIRTY_REASONS
el9	rpm	x86_64,aarch64	2	1	4	true	["Desired and Built membership sets differ"]
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
--json Emit the versioned JSON envelope false

Architectures print as canonical families. The JSON output carries both spellings, which is how you confirm that a DEB Dist renders binary-amd64 and binary-arm64:

"architectures":[{"family":"x86_64","ecosystem_arch":"amd64"},{"family":"aarch64","ecosystem_arch":"arm64"}]

sow dist new

Creates an ordinary, still-editable Dist. The only business argument is --format.

sow dist new el9 --format rpm -r pigsty
created el9: format=rpm architectures=x86_64,aarch64 members=0/0 generation=1 dirty=false
sow dist new trixie --format deb -r pigsty
created trixie: format=deb architectures=x86_64,aarch64 members=0/0 generation=2 dirty=false
Flag Description Default
--format FORMAT Required; rpm or deb
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

--format is mandatory and closed:

sow dist new x -r alpha
usage error: dist new requires --format rpm|deb
sow dist new x --format zip -r alpha
usage error: --format must be rpm or deb

There is no --arch. Architectures are inherited from the Workspace permit list; an advanced user narrows them per Dist by editing sow.yml. Policy (limit, exclude) is likewise configured in sow.yml, never re-modelled on the command line.

Re-running dist new with the same name and the same format converges and reports the current state. A name collision with a different format is rejected:

sow dist new el9 --format deb -r alpha
operation rejected: managed: operation rejected: dist "el9" already exists with format rpm

The three-way transaction

dist new is committed across three places at once: the sow.yml entry, the Repository database, and the on-disk tree. It goes through the SQLite Operation Journal (the Repository database already exists at this point, unlike repo new) and produces a new Built Generation with empty indexes.

That means a fresh Dist has a protocol-complete empty surface. An RPM Dist gets an empty repodata/ under every architecture view; a DEB Dist gets empty Packages, Packages.gz, the by-hash/SHA256/ entries and Release, plus InRelease/Release.gpg when signing is configured.

sow dist show

Read-only detail for one Dist.

sow dist show trixielim -r pgsql
dist trixielim:
  format: deb
  architectures: x86_64,aarch64
  desired_members: 3
  built_members: 3
  generation: 6
  status: clean
  dirty: false
  dirty_reasons: []
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
--json Emit the versioned JSON envelope false

The JSON form additionally exposes effective_config_sha256, the digest of the resolved Dist configuration. That digest is what makes a Dist dirty when you change limit, exclude or a signing key — the config identity changed, so the Built Generation no longer matches Desired.

sow dist show el9 -r pgsql --json
{"schema":"sow.cli/v1","command":"dist show","ok":true,"repository":"pgsql","operation":null,"result":{"name":"el9","format":"rpm","architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},{"family":"aarch64","ecosystem_arch":"aarch64"}],"desired_members":0,"built_members":0,"generation":"00000000000000000001","dirty":false,"status":"clean","effective_config_sha256":"a0b3ae2f943bc4fce951aaadda0fc8fb146ccf7944b0193a0dcc2b86ddc7ce7e","config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":1,"exclude":[{"kind":["debuginfo","debugsource"]}]}},"errors":[]}
sow dist show nope -r demo
operation rejected: managed: operation rejected: dist "nope" does not exist

sow dist rm

Removes a Dist’s Membership and derived indexes.

sow dist rm el9 -r pgsql
operation rejected: managed: operation rejected: dist "el9" is not empty; use --force
sow dist rm el9 -r pgsql -f
removed dist el9 from pgsql
Flag Description Default
-f, --force Remove membership and indexes but retain pool packages false
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

Dist removal does not delete Pool bytes

Removing a Dist never deletes a package from pool/. The whole Dist directory is moved into the recovery area and removed atomically; the pool is untouched:

sow dist rm el9 -r pgsql -f
removed dist el9 from pgsql

find pgsql -type f
pgsql/pool/e/epel-release/epel-release-7-5.noarch.rpm

Orphaned pool objects remain until sow gc proves they are unreachable from every safety root: current, retained, recovery, publication, and any active maintenance operation.

A Repository’s protected: true blocks Repository deletion only; normal Dist maintenance on a protected Repository continues to work.

Examples

Give one Repository an RPM and a DEB face:

sow dist new el9 --format rpm -r pgsql
sow dist new trixie --format deb -r pgsql

Add a beta channel with its own retention policy — create it, then set the policy in sow.yml and converge:

sow dist new el9-beta --format rpm -r pgsql
$EDITOR sow.yml          # el9-beta: { limit: 0 }
sow config check
sow build -r pgsql -d el9-beta

Which Dists are behind their Desired state:

sow dist ls -r pgsql --json | jq -r '.result.dists[] | select(.dirty) | .name'

Exit codes

Code Trigger
0 Listed, created, shown or removed; or dist new converged an existing Dist
1 Runtime I/O or renderer error creating the empty indexes
2 Usage error — missing or invalid --format, Workspace not found, ambiguous Repository
4 Repository lock held and --no-wait given or --timeout expired
5 Integrity or recovery error in the Operation Journal
6 Invalid name, unknown Dist, format conflict with an existing name, non-empty without -f

See also

5.6 - sow add

Add packages to Desired Membership, apply policy, and rebuild the affected indexes.

sow add is the main write path. It parses the packages you point at, derives their format and architecture from the package headers, applies the Dist’s membership policy, and — unless you pass --skip — rebuilds every affected index before it returns. When the command exits 0, clients can already see the new packages.

Synopsis

sow add PATH... [-R|--recursive] [--skip] [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]

Options

Flag Description Default
-R, --recursive Descend into subdirectories of a PATH directory off (top level only)
--skip Update Desired state only; do not build off
-j, --jobs N Parallel workers for parsing, hashing and rendering logical CPU count
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-d, --dist NAME Select a distribution; repeatable selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

Inputs and targets

PATH can be a file or a directory. A directory is scanned top level only unless you pass -R.

You must end up with exactly one Repository and at least one target Dist — see the selection rules. Mixed RPM/DEB batches are fine: each package is only considered for target Dists of its own format. A package with no compatible target fails.

SOW never infers the target from a manifest, a directory name, or the host OS.

sow add /srv/pkg/centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm /srv/pkg/epel-release-7-5.noarch.rpm -r pigsty -d el9
add repository=pigsty operation=8677129233475584643 accepted=2 failed=0 memberships=+2/-0 revision=3 generation=3 dirty=false
item input="/srv/pkg/centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm" status=accepted format=rpm coordinate="centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead dists=el9:accepted
item input="/srv/pkg/epel-release-7-5.noarch.rpm" status=accepted format=rpm coordinate="epel-release-0:7-5.noarch" sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab dists=el9:accepted

The summary line reports the Operation ID, per-item counts, the membership delta, the new Desired Revision, the Built Generation and whether the Repository is left dirty. Then one item line per input, in stable order.

Item statuses

Each item line carries an overall status plus a per-Dist verdict in dists=.

Status Meaning
accepted New Package Object created and at least one Membership added
reused The content already exists in this Repository; only Membership references may change
excluded Policy removed it from every target Dist — see the dists= field for excluded vs limited
failed The package was rejected; the error= field says why

reused is content idempotence: adding the same file twice never creates a second object or a duplicate Membership. A default repeated add also converges the selected Dist. If it is already current, the Generation stays put; if an earlier --skip or configuration change left it dirty, the reused input triggers the missing build and may advance the Generation:

sow add /srv/pkg/epel-release-7-5.noarch.rpm -r pigsty -d el9
add repository=pigsty operation=656950149626836753 accepted=1 failed=0 memberships=+0/-0 revision=4 generation=4 dirty=false
item input="/srv/pkg/epel-release-7-5.noarch.rpm" status=reused format=rpm coordinate="epel-release-0:7-5.noarch" sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab dists=el9:accepted

The same object added to a second Dist is also reused — the pool keeps one copy and gains a second Membership. Use --skip again when the intent is to retain a dirty batch instead of converging it.

Architecture is read, never guessed

add reads format and native architecture from the package header, then checks the Workspace permit list. An architecture outside the list fails the package and tells you exactly what to edit:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm -r pigsty -d el9
item input="/srv/pkg/centos-release-3.1-1.i386.rpm" status=failed error="managed: operation rejected: unknown rpm package architecture \"i386\"; supported rpm package architectures are [x86_64, aarch64, noarch] (canonical families [x86_64, aarch64, neutral]); use a supported package or update only supported architecture families in sow.yml"

It does not create a directory and does not modify sow.yml.

RPM noarch and DEB all are architecture-neutral. They create one Package Object and one Membership, and render into every effective architecture view of the target Dist. They do not spread to Dists you did not select with -d.

Policy: exclude and limit

After merging into the target Memberships, SOW re-evaluates exclude and then limit over the complete Dist candidate set. A package removed by policy is reported, not treated as a parse failure.

sow add /srv/pkg/debs -r pgsql -d trixielim
add repository=pgsql operation=4142220455201181493 accepted=3 failed=0 memberships=+3/-0 revision=6 generation=6 dirty=false
item input="/srv/pkg/debs/libpq5-dbgsym_18.3-1_amd64.deb" status=excluded format=deb coordinate="libpq5-dbgsym=18.3-1:amd64" sha256:cf491b9d9b218fa49ad2b41b4740d62cd972e1b515bf33677c2c3ead75acc60a dists=trixielim:excluded
item input="/srv/pkg/debs/libpq5_18.2-1_amd64.deb" status=excluded format=deb coordinate="libpq5=18.2-1:amd64" sha256:fa84dc641b7c686be2f9b512311ad0b74eac03e2afc9eff7e9af75b82b68ff41 dists=trixielim:limited
item input="/srv/pkg/debs/libpq5_18.3-1_amd64.deb" status=reused format=deb coordinate="libpq5=18.3-1:amd64" sha256:491992c502113627d44d0d66a2b189cdaa8accff293ebaf84fe10ccbc9da574c dists=trixielim:accepted
item input="/srv/pkg/debs/libpq5_18.3-1_arm64.deb" status=reused format=deb coordinate="libpq5=18.3-1:arm64" sha256:3a2f7ef7cddfa3dc06280ef59eda1dab9724d57499931ee80758b11531c1f40c dists=trixielim:accepted
item input="/srv/pkg/debs/pg-sample_1.17-1_all.deb" status=reused format=deb coordinate="pg-sample=1.17-1:all" sha256:f23581c5164a143e5e902232589adf1d30b73ba3857a692a11da607f246aacc3 dists=trixielim:accepted

Here trixielim has exclude: [{kind: [dbgsym]}] and limit: 1. The dbgsym package was excluded by rule; libpq5 18.2-1 lost to 18.3-1 under the version limit and is reported as limited. Both appear as excluded in the top-level status, and the dists= field distinguishes them.

Limit groups by (binary name, native architecture), so 18.3-1:amd64 and 18.3-1:arm64 both survive a limit: 1. A package can be accepted by one Dist and skipped by another in the same run.

Policy removals do not come back

exclude and limit remove real Desired Memberships. Relaxing the policy later does not resurrect them — leftover bytes in pool/ are not a candidate set. Re-run sow add.

Partial batches

Valid, conflict-free packages are committed even when siblings fail. Failed inputs stay where they are, each with its own error, and the command exits 3:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm /srv/pkg/centos-release-6-0.el6.centos.5.x86_64.rpm -r pigsty -d el9
add repository=pigsty operation=4623871845694427260 accepted=1 failed=1 memberships=+1/-0 revision=5 generation=5 dirty=false
item input="/srv/pkg/centos-release-3.1-1.i386.rpm" status=failed error="managed: operation rejected: unknown rpm package architecture \"i386\"; ..."
item input="/srv/pkg/centos-release-6-0.el6.centos.5.x86_64.rpm" status=accepted format=rpm coordinate="centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 dists=el9:accepted
managed: batch partially succeeded

If nothing is accepted, the whole operation is rejected with exit 6 and the Repository is unchanged:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm -r pigsty -d el9
operation rejected: managed: operation rejected: no input package was accepted

There is no rejected/quarantine directory.

–skip

--skip stops after the Desired state is committed. The public pool/ and dists/ bytes do not change, the Built Generation stays where it was, and the Repository becomes dirty. New package bytes are durably held in a private pending store until the next build publishes them.

sow add /srv/pkg/tree -R --skip -r pgsql -d trixie
add repository=pgsql operation=8405631664133415270 accepted=6 failed=0 memberships=+4/-0 revision=4 generation=3 dirty=true
sow status -r pgsql
repository=pgsql status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=trixie pending=4/2326 locked=false

pending=4/2326 is four objects totalling 2326 bytes waiting in the private store. They never appear in sow changes — only a successful build promotes them into the deliverable tree.

Use --skip for bulk imports, then converge once:

sow add /srv/build/ -R -r pgsql -d el9 --skip
sow status -r pgsql
sow build -r pgsql -j 12
sow check -r pgsql

Processing order

For the record, one add executes in this order:

  1. Take the Repository write lock and recover any unfinished Operation.
  2. Commit a planned Operation in SQLite.
  3. Parse inputs read-only; compute logical coordinates and input SHA-256 (plus, for RPM, the signature-neutral payload digest).
  4. Check the architecture permit list and look up existing coordinates.
  5. For genuinely new coordinates only, run optional RPM signing on a stage copy and compute the final SHA-256, then verify content and path uniqueness.
  6. Merge target Memberships, then apply exclude and limit over the complete Dist set.
  7. Commit the Desired state; new bytes go to the private pending content store.
  8. Unless --skip, publish still-needed pending objects into pool/ and render indexes — each Dist is built at most once per command.

Input files are never modified, moved or deleted, in any mode.

RPM signing modes

Managed RPM package signing is configured in sow.yml under signing.rpm.packages.mode; there is no command-line override.

Mode Behavior
never Keep the input bytes exactly
fill Sign when unsigned or when the signature is not trusted; keep bytes when a trusted_keys signature verifies. Default when a key is configured
always Ensure the final package is validly signed by the configured key; re-sign a stage copy otherwise

Without a configured key only never is available.

Because a signature embeds non-deterministic fields, SOW cannot re-sign and then compare final hashes. Retry idempotence works on the coordinate instead: identical input bytes are reused directly; an identical RPM signature-neutral digest is reused when the existing object satisfies the current policy. A different payload digest, or an existing object that no longer satisfies the policy, is a hard conflict — add will not silently re-sign a coordinate in place.

Exit codes

Code Trigger
0 Every input accepted or reused; indexes rebuilt (or skipped with --skip)
1 Runtime I/O, parser, renderer or signing failure
2 Usage error, Workspace not found, or ambiguous Repository/Dist selection
3 Partial batch — at least one item committed and at least one failed
4 Repository lock held and --no-wait given or --timeout expired
5 Integrity or recovery error, including a build that failed after applied
6 Nothing accepted — unsupported architecture, no compatible target Dist, or a coordinate conflict

See also

5.7 - sow rm

Remove Desired Membership from selected Dists, with a no-write preview mode.

sow rm takes packages out of the Desired Membership of the Dists you select and, by default, rebuilds the affected indexes immediately. It does not delete bytes from pool/ — membership and content are separate concepts, and reclamation is the separate conservative sow gc operation.

Synopsis

sow rm PACKAGE... [-c|--check] [--skip] [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]

Options

Flag Description Default
-c, --check Preview only; compute and print the plan without writing anything off
--skip Update Desired state only; do not build off
-j, --jobs N Parallel workers logical CPU count
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-d, --dist NAME Select a distribution; repeatable selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

--check and --skip are mutually exclusive:

sow rm epel-release -c --skip
usage error: --check and --skip are mutually exclusive

Package references

PACKAGE accepts five forms. The full grammar and disambiguation rules are on Package References; the short version:

Form Example
Content hash sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab
RPM coordinate rpm:epel-release-0:7-5.noarch
DEB coordinate deb:libpq5=18.3-1:amd64
Full filename epel-release-7-5.noarch.rpm
Bare binary name epel-release

A bare name means every version and native architecture of that name in the selected Dists — that is what makes sow rm patroni a useful takedown command. An ambiguous short reference that is not a bare name fails and lists the candidates instead of guessing.

sow ls prints exact sha256: references and canonical coordinates, so you never have to assemble one by hand.

A reference matching nothing is a rejection, not a silent success:

sow rm nosuch -r pigsty -d el9
operation rejected: managed: operation rejected: package reference not found: package reference "nosuch" matches no Desired Membership

There is no --allow-empty, no --all, no --yes and no --source-list.

Preview with –check

-c/--check computes exactly what would be removed, what policy would then decide, and which files an immediate build would touch — and writes nothing at all.

sow rm centos-release -r demo -d el9 -c
preview repository=demo operation= dists=el9 memberships=2 revision=2 generation=00000000000000000003 dirty=false changes=2
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead
change op=update phase=pointer path="dists/el9/aarch64/repodata/repomd.xml" size=1509 sha256:1cfe38698967d11384f1a985618d75f5e690d1284accf951262fc663fa9afc81
change op=update phase=pointer path="dists/el9/x86_64/repodata/repomd.xml" size=1509 sha256:1cfe38698967d11384f1a985618d75f5e690d1284accf951262fc663fa9afc81

Note both centos-release versions matched the bare name. The change lines are a real delivery plan in payload → metadata → pointer → delete phase order. Use --json when another program needs the corresponding removed[] and changes[] arrays.

Preview uses the same candidate-configuration and integrity preflight as the mutation. A preview that fails that guard is not evidence that the write would succeed.

--check deliberately does not take the write lock. Combining it with lock flags is a usage error, so nobody can believe a preview is queueing behind a writer:

sow rm centos-release -r pigsty -d el9 -c -T 5s
usage error: rm --check does not accept --timeout or --no-wait

Default behavior: remove and rebuild

Without --check or --skip, rm commits the Desired change and rebuilds every affected Dist before returning. Pool objects stay on disk.

sow rm 'rpm:centos-release-0:6-0.el6.centos.5.x86_64' -r demo -d el9
removed repository=demo operation=2283442100870457321 dists=el9 memberships=1 revision=2 generation=00000000000000000003 dirty=false changes=8
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16

The summary is followed by one change line per affected file (eight in this run). Add --json to receive the same result as a stable standard envelope.

Removing the last member of a Dist is fine. SOW still renders a valid, signed-if-configured empty index — an empty Packages with a verifiable InRelease, or empty per-architecture repodata/.

–skip

--skip commits the Desired change and marks the Repository dirty without touching the public tree. The old Built Generation stays completely self-consistent for clients.

sow rm 'rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64' --skip -r demo -d el9
removed repository=demo operation=314678479940914827 dists=el9 memberships=1 revision=4 generation=00000000000000000004 dirty=true changes=0
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead
sow status -r pigsty
repository=pigsty status=dirty ready_to_copy=false revision=6 generation=5 dirty_dists=el9 pending=0/0 locked=false

changes is empty because nothing was built. Run sow build to converge.

Policy interaction

Removals are Desired-state edits, so policy is re-evaluated over the resulting candidate set — a removal will never resurrect a package that limit previously pushed out. If you remove libpq5 18.3-1 from a limit: 1 Dist, 18.2-1 does not come back; add it again explicitly.

Examples

Safe takedown — preview first, then execute:

sow rm patroni -r pgsql -d el9 -c
sow rm patroni -r pgsql -d el9

Remove one exact object from two Dists at once:

sow rm sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab -r pgsql -d el9 -d el9-beta

Batch several removals, then rebuild once:

sow rm old-tool legacy-agent -r pgsql -d el9 --skip
sow build -r pgsql -d el9
sow check -r pgsql

Feed the preview plan to another tool:

sow rm patroni -r pgsql -d el9 -c --json | jq -r '.result.changes[] | "\(.phase)\t\(.op)\t\(.path)"'

Exit codes

Code Trigger
0 Memberships removed and rebuilt, or a --check preview printed
1 Runtime I/O or renderer failure
2 Usage error — --check with --skip, --check with lock flags, ambiguous selection, Workspace not found
3 Partial batch — at least one reference removed and at least one failed
4 Repository lock held and --no-wait given or --timeout expired
5 Integrity or recovery error
6 A reference matched nothing, or an ambiguous non-bare reference

See also

5.8 - sow ls

List Desired and Built package membership for the selected Dists.

sow ls is a read-only query over Package Objects and Dist Membership. It shows what each selected Dist should contain and whether that membership is present in the current Built Generation.

Synopsis

sow ls [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
-d, --dist NAME Select a Dist; repeatable selection rules
--json Emit the sow.cli/v1 envelope false

There is no --pool, --match, or output-format flag.

Output

sow ls -r pigsty -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab	rpm:epel-release-0:7-5.noarch	el9	el9	pool/e/epel-release/epel-release-7-5.noarch.rpm
Column Meaning
SHA256 Immutable content identity; valid input to show and rm
COORDINATE Canonical rpm: or deb: package reference
DISTS Desired Membership across the selected scope
BUILT_DISTS Membership present in the current Built Generation
POOL_PATH Repository-relative immutable payload path

The first line reports dirty=true when Desired and Built state differ. An empty BUILT_DISTS field means the package is desired but clients cannot see it yet. Run sow build to converge.

An object shared by several selected Dists appears once, with comma-separated membership values. An empty Dist has a header and no package rows; that is a successful result.

Selection

ls requires an unambiguous Dist set. In a multi-Dist Repository, pass one or more -d values or run from inside <repo>/dists/<dist>/.

sow ls -r pigsty
workspace discovery error: managed: workspace discovery or configuration error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

The command takes no write lock and does not hash package files. --json returns the same rows in result.packages.

Examples

List exact references for all objects not yet built:

sow ls -r pgsql -d el9 --json |
  jq -r '.result.packages[] | select(.built_dists | length == 0) | .sha256'

List pool paths in deterministic order:

sow ls -r pgsql -d el9 --json | jq -r '.result.packages[].pool_path' | sort

Exit codes

Code Trigger
0 Membership printed, including an empty list
1 Runtime I/O failure
2 Usage error, Workspace not found, or implicit Repository/Dist selection is ambiguous
5 Repository state database unreadable or inconsistent
6 Explicit Repository or Dist is not configured

See also

5.9 - sow show

Inspect one Package Object, including identity, normalized facts, storage, signature, and membership.

sow show resolves one package reference in the selected Repository and prints the complete Package Object. It is read-only and takes no write lock.

Synopsis

sow show PACKAGE [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
-d, --dist NAME Narrow candidates to these Dists; repeatable Repository scope
--json Wrap the result in the sow.cli/v1 envelope false

Package reference

PACKAGE accepts a sha256:<hex> content identity, canonical rpm:<NEVRA> or deb:<name>=<version>:<arch> coordinate, full package filename, or bare binary name. See Package References for the exact grammar.

A bare name must resolve to exactly one Package Object in the selected scope. Unlike sow rm foo, which removes every matching version from Desired Membership, sow show foo refuses ambiguity and prints the candidates:

sow show libpq5 -r pgsql -d trixie
operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1:amd64 sha256:fa84dc64..., deb:libpq5=18.3-1:amd64 sha256:491992c5..., deb:libpq5=18.3-1:arm64 sha256:3a2f7ef7...

Copy an exact coordinate or SHA-256 from the error or from sow ls and retry.

Output

Without --json, show prints the identity, storage path, and Desired/Built locations in a compact human-readable form:

sow show centos-release-6-0.el6.centos.5.x86_64.rpm -r demo -d el9
package repository=demo coordinate="centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 format=rpm architecture=x86_64 size=19776 storage=pool
pool=pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm
dists=el9 built_dists=el9

Adding --json returns the complete Package Object under result in the standard envelope, including the normalized fields below.

Field Meaning
canonical_arch x86_64, aarch64, or neutral for RPM noarch / DEB all
kind Policy class: main, debuginfo, debugsource, llvmjit, dbgsym, or dbg
source Normalized source-package name
payload_sha256 RPM signature-neutral digest used for re-signing idempotence
signature_key Embedded package-signature key ID, when present
storage pending before build; pool once published into the repository tree
dists / built_dists Desired and current Built Membership

-d narrows candidate resolution; it does not alter package identity.

Exit codes

Code Trigger
0 One Package Object printed
1 Runtime I/O failure
2 Usage error, Workspace not found, or implicit Repository selection is ambiguous
5 Repository state database unreadable or inconsistent
6 Explicit scope is not configured, or the reference matched nothing/several objects

See also

  • sow ls — obtain exact identities from Dist Membership
  • sow where — search across Repositories
  • sow rm — remove matching Desired Membership
  • JSON Output — complete result schema

5.10 - sow where

Locate one Package Object across Repositories and Dists in a Workspace.

sow where answers which Dists in the Workspace still carry one Package Object. It is read-only, searches every Repository by default, and takes no write lock.

Synopsis

sow where PACKAGE [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Restrict the search to one Repository all Repositories
-d, --dist NAME Restrict the search to named Dists; repeatable all Dists
--json Emit the sow.cli/v1 envelope false

Reference resolution

PACKAGE uses the same grammar as sow show: SHA-256 identity, canonical RPM/DEB coordinate, full filename, or bare name.

Resolution happens across the complete selected scope. A bare name must identify one Package Object; different objects with the same binary name are ambiguous even when they live in different Repositories. Use -r/-d, or supply an exact coordinate or SHA-256.

Output

Without --json, where prints a summary followed by one line per location:

sow where 'rpm:centos-release-0:6-0.el6.centos.5.x86_64'
reference="rpm:centos-release-0:6-0.el6.centos.5.x86_64" locations=1
repository=demo coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 dists=el9 built_dists=el9

Each location reports both Desired dists and current built_dists. This makes the command useful for answering whether a removed or superseded build is still client-visible anywhere.

With --json, the same object appears under result. A missing reference is an expected rejection, not an empty success:

sow where nosuchpkg
operation rejected: managed: operation rejected: package reference "nosuchpkg" was not found in the selected Workspace scope

Example

List every location still serving an exact build:

sow where 'rpm:patroni-0:3.0.4-1.noarch' --json |
  jq -r '.result.locations[] | "\(.repository)/\(.dists | join(","))"'

Exit codes

Code Trigger
0 One resolved Package Object and its locations printed
1 Runtime I/O failure
2 Usage error or Workspace not found
5 A Repository state database is unreadable or inconsistent
6 Explicit Repository/Dist is not configured, or the reference matched nothing/was ambiguous

See also

5.11 - sow status

Read Repository convergence, readiness, pending payload, recent Operation, and lock state without deep verification.

sow status is the cheap Repository health query. It reads state but does not hash files, verify signatures, recover Operations, build metadata, or take the write lock.

Synopsis

sow status [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
-d, --dist NAME Restrict status to these Dists; repeatable all Dists
--json Emit the sow.cli/v1 envelope false

Repository states

Every Repository tracks a Desired Revision in SQLite and the Built Generation represented by its public dists/ tree.

State Meaning Public view
clean Desired state matches Built state current, complete Generation
dirty Desired state is ahead, commonly after --skip or a config change previous complete Generation
recovering A non-terminal Operation must be recovered by the next write command last completed protocol pointer
error Automatic recovery cannot choose safely last completed view; no overwrite attempted

Dirty never means a half-written repository. Readers see either the old complete view or the new complete view because protocol pointers are switched last.

Output

sow status -r pgsql
repository=pgsql status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=trixie pending=4/2326 locked=false

The human line reports:

  • Repository state and ready_to_copy;
  • Desired Revision and current Built Generation;
  • affected Dists;
  • pending object count and bytes;
  • write-lock state.

The JSON result additionally includes dirty_reasons and the most recent Operation:

{
  "repository": "demo",
  "status": "dirty",
  "ready_to_copy": false,
  "desired_revision": 5,
  "built_generation": "00000000000000000004",
  "dirty_dists": ["el9"],
  "dirty_reasons": ["dist el9 Desired and Built membership sets differ"],
  "pending": {"count": 1, "bytes": 19776},
  "repository_locked": false
}

ready_to_copy=false is a hard warning. true is only a cheap state result, not a byte-level proof; run sow check before delivery.

Read-only contract

status never migrates or repairs state. If the Repository database cannot be read safely, the command exits 5; run the maintenance command named by the diagnostic before retrying. In particular, back up and run sow repo migrate for every v0.3 Repository before using the 0.4 read surface.

Exit behavior

status returns 0 for every readable state, including dirty, recovering, and error. Scripts should inspect the structured state rather than treating those conditions as command failures.

Code Trigger
0 Repository state is readable
1 Runtime I/O failure
2 Usage error, Workspace not found, or implicit Repository selection is ambiguous
5 State database unreadable or inconsistent
6 Explicit Repository or Dist is not configured

See also

5.12 - sow build

Converge Desired Membership and renderer configuration into a complete Built Generation.

sow build is the explicit Desired-to-Built convergence command. It acquires the Repository write lock, recovers any decidable unfinished Operation, renders and verifies a complete Generation, then switches protocol pointers last.

Synopsis

sow build [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]
Flag Meaning Default
-j, --jobs N Parallel workers; must be at least 1 logical CPU count
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
-d, --dist NAME Build named Dists; repeatable all affected Dists
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Wrap the result in the sow.cli/v1 envelope false

Without -d, SOW converges every affected Dist in the selected Repository. With -d, only those Dists converge; unselected changes remain dirty.

Result

Without --json, build prints one human-readable summary:

sow build -r demo -d el9
built repository=demo operation=2769214987359113555 dists=el9 revision=4 generation=00000000000000000005 dirty=false

Use --json for the command-specific object inside the standard envelope.

No-op builds

When membership, relevant policy, renderer settings, and signing configuration already match the Built Generation, build is an idempotent no-op and does not increment the Generation:

sow build -r demo -d el9
build repository=demo dists=el9 already current (noop) revision=4 generation=00000000000000000005 dirty=false

Policy convergence

build re-evaluates the current exclude and limit policy. Tightening policy may remove Desired Membership. Loosening policy does not reconstruct historical members from leftover pool bytes; run sow add again for packages you want to restore.

Commit and recovery

SOW stages new metadata on the same filesystem, verifies it, then switches mutable protocol pointers last. RPM checksum-named metadata and APT by-hash keep old and new readers self-consistent.

Pending package promotion is a bounded, single-writer group commit. Each batch contains at most 512 objects or 1 GiB: SOW creates Pool links, persists every distinct target parent, then removes pending names and persists the shared pending directory. An interruption can leave a pending-only, exact dual-link, or Pool-only state, all recoverable from the journal; it cannot durably lose both names.

One Operation may cover several Dists. Each Dist always exposes a complete view; when build returns, every included Dist belongs to the same Built Generation.

Before starting new work, build attempts forward recovery or safe rollback of a non-terminal Operation. If journal, database, and filesystem evidence contradict each other, the Repository enters error and build refuses to guess. There is no force-repair flag.

Progress events

Long builds append structured build_progress records to the Operation log. Each event contains phase, completed, total, and jobs. Current phases are:

  • rendering;
  • promoting_payload;
  • publishing_dists;
  • normalizing_public_tree;
  • finalizing.

These events do not advance the Operation state and deliberately do not checkpoint SQLite after every update. They are audit/observability records, not recovery decisions. Inspect them with sow log OPERATION.

Metadata signing

Managed metadata signing is configured only in sow.yml; there is no command-line key override. Changing a configured key reference or fingerprint makes affected Dists dirty, and the next build re-signs their metadata.

  • RPM: always writes repodata/repomd.xml; writes repomd.xml.asc when configured.
  • DEB: always writes Release; writes InRelease and Release.gpg when configured.

Exit codes

Code Trigger
0 Converged successfully or nothing to do
1 Renderer, signing, or filesystem failure
2 Usage error, Workspace not found, or implicit Repository selection is ambiguous
4 Repository write lock unavailable
5 Recovery cannot complete safely, or Repository is in error
6 Explicit scope is not configured, or current configuration rejects existing state

See also

5.13 - sow check

Run the full read-only integrity and delivery-readiness verification pipeline.

sow check is the deep read-only gate for a managed Repository. It hashes bytes, validates state, reconstructs expected views, and verifies declared signatures. It never repairs, builds, recovers an Operation, or takes the write lock.

Synopsis

sow check [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
Flag Meaning Default
-j, --jobs N Parallel verification workers; at least 1 logical CPU count
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
-d, --dist NAME Verify named Dists; repeatable all Dists
--json Emit the sow.cli/v1 envelope false

Verification layers

In steady state, the checker reports nine ordered layers:

Layer Verification checked counts
config sow.yml parses and validates for the Repository configuration objects
state SQLite quick_check, foreign keys, journal, and recovery evidence one state database
public-modes File and directory permissions across the served tree inspected paths
retained Explicit retained records and frozen Generation manifests retained records
package-bytes SHA-256 of pool and private pending payloads Package Objects
desired-membership Membership rows resolve under current policy memberships
index Rendered indexes match the membership they claim Dists
signature Every declared metadata and package signature verifies signatures
generation-manifest Built Generation manifest matches files on disk one manifest

During a non-terminal Repository layout transition, check instead reports config, state, public-modes, then a conditional layout-transition layer and stops. It returns not-ready until the diagnosed repo migrate operation completes or is aborted before commit.

Physical evidence and I/O contract

package-bytes never trusts a cached fingerprint as a substitute for authenticity. Each run hashes every unique physical payload exactly once, using descriptor-bound evidence keyed by device, inode, size, mtime, and ctime. Hard links to the same inode share that proof; retained Generations, final manifest traversal, and changes reuse it without another payload scan. The checked column counts logical objects, not the number of full-file streams.

A DEB or unsigned RPM needs one complete payload stream. A signed RPM uses at most one additional main-header-to-EOF stream to verify every signature packet against all candidate trust rings; the cost does not grow with Dist, retained Generation, or trust-ring count. A forged or concurrently replaced file invalidates the descriptor evidence and fails closed.

sow check
repository=pigsty status=clean ready_to_copy=true revision=5 generation=5
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=67
retained	ok=true	checked=0
package-bytes	ok=true	checked=8
desired-membership	ok=true	checked=8
index	ok=true	checked=2
signature	ok=true	checked=9
generation-manifest	ok=true	checked=1

Dirty is not deliverable

A dirty Repository can have nine individually valid layers: the old Built Generation is intact and the new Desired state is valid. It still fails the delivery gate because the two do not match:

sow check
repository=pigsty status=dirty ready_to_copy=false revision=6 generation=5
...
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

The exit code is 5. Run sow build and check again. Do not weaken a release pipeline to accept this state.

Exit codes

Code Trigger
0 Every layer passes and the Repository is ready to copy
1 I/O failure during verification
2 Usage error, Workspace not found, or implicit Repository selection is ambiguous
5 A verification layer failed, or Repository is not deliverable
6 Explicit Repository or Dist is not configured

See also

5.14 - sow changes

Diff Built Generations as a deterministic Repository-relative file delivery plan.

sow changes compares Built Generations. It reports physical Repository-relative file changes; it does not show unbuilt Desired changes and is not a remote transaction protocol.

Synopsis

sow changes [BASE_GENERATION] [-C|--workdir DIR] [-r|--repo NAME] [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
--json Emit the sow.cli/v1 envelope false

The command is Repository-wide and rejects -d/--dist.

Output

sow changes
base=4 generation=5 dirty=false
add	payload	pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm	19776	ffd9e7bd...
add	metadata	dists/el9/x86_64/repodata/5bc463cb...-primary.xml.gz	1460	5bc463cb...
update	pointer	dists/el9/x86_64/repodata/repomd.xml	1514	05d3d5bf...
delete	delete	dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz	0	

Columns are operation, phase, Repository-relative path, size, and SHA-256.

Field Values
operation add, update, delete
phase payload, metadata, pointer, delete

Phases describe how SOW constructed the local Generation. Do not replay individual rows into a live remote tree. Use sow publish, or stage and atomically switch a complete copy.

Base Generation

Without an argument, SOW compares the current Built Generation with its predecessor.

BASE_GENERATION is a decimal integer in the inclusive range 0..current. Base 0 produces the complete delivery manifest for the current Generation, excluding private sow.yml and .sow/. Using the current Generation as base produces an empty plan. A Repository never built also yields an empty 0 -> 0 plan.

sow changes 99
operation rejected: managed: operation rejected: base generation 99 is outside 0..2

Dirty and recovery states

When Desired state is dirty, the header says dirty=true, but the plan still ends at the current Built Generation. Private pending payloads are excluded because they are not deliverable yet.

When the Repository is recovering or error, changes refuses to emit a plan: pending file actions must not be mistaken for a completed Generation.

Examples

Produce a complete manifest:

sow changes 0 -r pgsql --json > pgsql-current.json

Filter one Dist by path after producing the Repository-level plan:

sow changes -r pgsql --json |
  jq '.result.changes[] | select(.path | startswith("dists/el9/"))'

Exit codes

Code Trigger
0 Plan printed, including an empty plan
1 Runtime I/O failure
2 Usage error, -d supplied, Workspace not found, or implicit Repository selection is ambiguous
5 Repository is recovering or error, or state evidence is inconsistent
6 Explicit Repository is not configured, or Base Generation is outside the valid range

See also

5.15 - sow publish

Publish the current verified Generation to a configured filesystem or R2 target.

sow publish delivers one Repository’s current Built Generation to a named target from the targets: map in sow.yml. The target binds its Repository and provider; the command does not accept --repo or --dist.

Synopsis

sow publish TARGET [--abort | --rebind] [-C|--workdir DIR] [-T|--timeout DUR | -N|--no-wait] [--json]
Flag Meaning Default
--abort Abandon a reconciled attempt that has not reached durable commit intent false
--rebind Confirm and record permitted target name/public endpoint/cache-TTL changes false
-C, --workdir DIR Workspace discovery start directory current directory
-T, --timeout DUR Maximum Repository-lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the sow.cli/v1 envelope false

TARGET must name a configured filesystem or r2 publication target. --abort and --rebind are mutually exclusive.

Publication protocol

Before delivery, SOW requires a completed Built Generation and verifies that the public tree is the exact frozen Generation manifest. It then plans and applies objects in this order:

  1. immutable payloads;
  2. checksum-addressed metadata;
  3. mutable protocol pointers;
  4. verification and durable checkpoint.

The exact object set, receipts, phase, and commit intent are recorded so an interrupted publication can be reconciled. Repeating a publication already at the current Generation is an idempotent no-op.

sow publish local
published demo generation=00000000000000000005 to local (filesystem): phase=grace objects=14
sow publish local
publication demo generation=00000000000000000005 to local is already current (noop)

Rebind mutable target settings

The first successful publish durably binds a Repository, storage namespace, and target identity. Later configuration drift is rejected rather than silently adopted. When the diagnostic explicitly points to --rebind, review the edit and confirm it with:

sow publish prod --rebind

If the map key itself was renamed, invoke the new target name. Rebind preserves active attempt and checkpoint identities and appends an immutable operator-confirmed binding revision.

May change Immutable; configure a new target
target name Repository identity
public_endpoint provider, storage endpoint, or region
max_cache_ttl bucket or prefix

Rebind takes the same Workspace and Repository locks as publication and rechecks the immutable fields inside the database transaction. It may resume an active commit-intent attempt forward. It refuses a TTL change while target maintenance is pending, and refuses a filesystem public_endpoint change during conditional-delete maintenance. A first bind must use ordinary publish, not --rebind.

Abort and recovery

--abort is valid only before durable commit intent. SOW reconciles objects already created, keeps the evidence required for later safety decisions, and abandons the attempt without copying or deleting more remote objects.

After commit intent, recovery is forward-only. Rerun sow publish TARGET; do not use --abort.

Public visibility checks

Provider storage success is not enough: publication verifies the canonical public_endpoint before recording its checkpoint. HTTP(S) targets use ordinary GET as the final authority. A no-cache probe may accelerate revalidation, but only a later ordinary GET can pass. Stale content and missing objects are retried up to max_cache_ttl; 408, 425, 429, and 5xx failures get a short bounded retry window. Header wait and body idle progress have separate deadlines, and an oversized body fails closed.

Filesystem targets may use file:// or HTTP(S). Their conditional-deletion absence check requires the exact file identity for file://, or canonical 404/410 visibility for HTTP(S). R2 requires an HTTP(S) public endpoint; R2 target GC remains report-only and does not perform remote deletion.

Safety boundaries

  • SOW publishes only configured targets; there is no arbitrary destination argument.
  • Unbuilt Desired changes are never included. A dirty Repository can therefore publish its previous complete Built Generation; run build first when the target must reflect current Desired state.
  • Layout transitions and contradictory recovery evidence block publication. Decidable unfinished Dist work is recovered before the source Generation is selected.
  • Object order protects package-manager pointers from referencing absent content.
  • Publication does not make an external web server, bucket policy, DNS route, or cache correct; those remain deployment concerns.

Exit behavior

Code Trigger
0 Publication completed or target was already current
1 Filesystem, provider, network, verification, or binding conflict (including required rebind)
2 Usage, Workspace discovery, or invalid sow.yml error
4 Repository write lock unavailable
5 Local or publication recovery evidence is inconsistent, or source is not deliverable
6 Target is missing/unsafe, or another safety precondition rejects publish/abort/rebind

See also

5.16 - sow retain

Add, list, and remove explicit retained-Generation roots for local garbage collection.

sow retain manages explicit local Generation roots. retain add can freeze only the current Built Generation; after later builds make it historical, its required package payloads remain protected.

Synopsis

sow retain add GENERATION [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]
sow retain ls             [-C|--workdir DIR] [-r|--repo NAME] [--json]
sow retain rm GENERATION  [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]

GENERATION must be a decimal integer greater than zero.

retain add

Requires GENERATION to equal the current Built Generation, verifies it, freezes its manifest under private Workspace state, and adds an explicit GC root. Older Generations cannot be recreated after the fact with retain add.

sow retain add 12 -r pgsql
retained generation 00000000000000000012: /srv/sow/.sow/pgsql/retained/00000000000000000012

The retained record protects payloads; it does not switch the current view or publish anything. Adding an already retained Generation is idempotent only when the verified record agrees with current evidence.

retain ls

Lists explicit retained records. It is read-only and therefore accepts neither lock options nor --dist.

sow retain ls -r pgsql
GENERATION	RECORD_IDENTITY	PATH
00000000000000000012	678beeae...	/srv/sow/.sow/pgsql/retained/00000000000000000012

An empty list is a successful result.

retain rm

Removes only the explicit retained root:

sow retain rm 12 -r pgsql
removed retained generation 00000000000000000012

It does not delete package bytes. Removing a Generation that is not retained is an idempotent no-op. A later local sow gc may reclaim payloads only if no other safety root reaches them.

Options

Flag Commands Meaning
-C, --workdir DIR all Workspace discovery start directory
-r, --repo NAME all Select a Repository
-T, --timeout DUR add, rm Maximum write-lock wait
-N, --no-wait add, rm Fail immediately when locked
--json all Emit the sow.cli/v1 envelope

Exit behavior

Code Trigger
0 Requested operation completed, including an empty list
1 Filesystem or runtime I/O failure
2 Invalid Generation syntax, discovery error, or implicit Repository selection is ambiguous
4 Write lock unavailable for add or rm
5 Generation manifest or Repository state is inconsistent
6 Explicit Repository is not configured, retain add does not name the current Built Generation, or another safety rule rejects the request

See also

5.17 - sow gc

Collect unreachable local payloads or perform conservative maintenance for one publication target.

sow gc has two deliberately separate modes. With no positional target it collects unreachable local pool payloads. With TARGET it maintains one configured publication target.

Synopsis

sow gc          [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]
sow gc TARGET   [-C|--workdir DIR]                  [-T|--timeout DUR | -N|--no-wait] [--json]
Flag Meaning Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository for local GC only selection rules
-T, --timeout DUR Maximum Repository-lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the sow.cli/v1 envelope false

gc TARGET -r NAME is a usage error because the target already binds a Repository. --dist is not accepted in either mode.

Local GC

Local GC deletes only pool payloads unreachable from every safety root:

  • the current Built Generation;
  • explicit retain records;
  • recovery and non-terminal Operation state;
  • publication attempts and evidence;
  • active maintenance work.

The operation is journaled. When payloads are removed, the Repository advances to a new Generation. When nothing is eligible, it is an idempotent no-op.

sow gc -r pgsql
local gc pgsql: generation=00000000000000000013 objects=4 bytes=1834200

Target GC

Target maintenance is provider-specific and uses publication checkpoints, absence evidence, and configured cache grace:

Provider Behavior
filesystem Conditionally delete eligible objects only after grace and recorded storage/public absence checks
r2 Persist an exact report-only retained-candidate set; never issue object deletion
sow gc prod
target gc pgsql/prod (filesystem): phase=done candidates=14 deleted=8 retained=6 pending=0

A no-op means no maintenance is due, not that the target was exhaustively revalidated.

Exit behavior

Code Trigger
0 GC completed or nothing was eligible
1 Filesystem, provider, network, or runtime failure
2 Usage, Workspace discovery, invalid sow.yml, or implicit Repository ambiguity
4 Repository write lock unavailable
5 Recovery, state, receipt, or manifest evidence is inconsistent
6 Explicit Repository/target is not configured or safe, or deletion is rejected by a safety precondition

See also

5.18 - sow export

Export one built RPM Dist architecture as a standalone compatibility repository.

SOW provides one export subcommand: sow export rpm-leaf. It creates an external, standalone RPM repository whose repodata uses local pool/... hrefs.

Synopsis

sow export rpm-leaf DIST ARCH DIR [--hardlink] [-C|--workdir DIR] [-r|--repo NAME] [--json]
Argument Requirement
DIST Canonical configured RPM Dist name
ARCH x86_64 or aarch64
DIR Absent or empty destination outside Repository, private-state, and filesystem-target roots
Flag Meaning Default
--hardlink Use hard links for trusted, same-filesystem, read-only output copy files
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a Repository selection rules
--json Emit the sow.cli/v1 envelope false

The command accepts no --dist, jobs, timeout, or lock option.

Output

sow export rpm-leaf el9 x86_64 /srv/export/el9-x86_64
exported RPM leaf el9/x86_64 generation=00000000000000000012 method=copy packages=84 to /srv/export/el9-x86_64

The destination contains:

  • rewritten RPM repodata with local payload hrefs;
  • the required package subtree;
  • an export manifest;
  • .sow-export.json provenance.

The source must be a completed Built Generation. The export is a separate artifact: it is not Desired Membership, a Built Generation, publication input, or a GC root.

Copying is the safe default. --hardlink is an explicit optimization for an output on the same filesystem that consumers cannot mutate. A hard-linked payload shares an inode with SOW’s pool; do not use this mode for writable or untrusted destinations.

SOW rejects output that overlaps a configured filesystem publication root. This prevents an export from being mistaken for, or modifying, a managed publication target.

Exit behavior

Code Trigger
0 Standalone RPM leaf exported
1 Filesystem, copy, hard-link, or metadata-write failure
2 Invalid syntax, malformed Dist/architecture token, discovery, or implicit Repository ambiguity
5 Source Generation or Repository state is inconsistent
6 Explicit Repository is not configured, Dist is not RPM, view/signer is unavailable, or destination is unsafe/non-empty/overlapping

See also

5.19 - sow log

Read the Operation audit ledger, export it as JSONL, and prune eligible terminal records.

Every write command inside a Repository commits an application-level Operation to that Repository’s SQLite database before it produces any external file side effect. That record is what makes crash recovery possible — and once the Operation reaches a terminal state, the same record is your audit trail. sow log reads it.

Synopsis

sow log [OPERATION] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME] [--json]
sow log export [FILE] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]
sow log prune BEFORE [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]

Operation lifecycle

Understanding the state field is most of understanding the log:

planned → staged → applied → built → done
                        └────────→ done_dirty
   any nonterminal → recovering → built / rolled_back
   pre-apply error  → failed
State Meaning
planned Command, arguments, targets and intended actions are durably recorded
staged New packages/metadata written to temporary locations and verified
applied Desired state and required private pending payloads committed
built The complete static Generation has been switched in
done Terminal — a normal successful command
done_dirty Terminal — --skip was given, so the public tree deliberately stayed behind
failed Terminal — the operation failed before applied, nothing was committed
rolled_back Terminal — a post-applied failure the process could safely undo
recovering Non-terminal; the next write command must complete or roll it back

Workspace lifecycle commands (init, repo new, repo rm) use the Workspace file journal instead and do not appear in a Repository’s SQLite log. dist new/dist rm do appear — the Repository database already exists at that point.

sow log

With no argument, prints the 50 most recent Operations, newest first.

sow log -r pigsty

Output excerpt, one Operation object from the operations array:

{
  "id": "4262183287563704350",
  "kind": "build",
  "state": "done",
  "payload_json": "{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"dists\":[\"el9\"],\"build_dists\":[\"el9\"],\"manifest_sha256\":\"678beeae...\"}",
  "result_json": "{\"dists\":1,\"dropped_pending\":[]}",
  "created_at": "2026-08-04T04:07:40.334787Z",
  "updated_at": "2026-08-04T04:07:40.907125Z"
}

payload_json records the intent — including config_sha256, the digest of the configuration in force, and manifest_sha256 for the resulting Generation. result_json records the outcome. A failed Operation additionally carries error_class and error_message:

{
  "id": "5995346754219751025",
  "kind": "add",
  "state": "failed",
  "result_json": "{\"accepted\":0,\"failed\":1}",
  "error_class": "rejected",
  "error_message": "no input package was accepted"
}
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-d, --dist NAME Show only Operations touching this Dist all
--json Emit the versioned JSON envelope false

One Operation in detail

Pass an Operation ID to get its full state transitions, timing, packages, memberships and file actions.

sow log 4262183287563704350 -r pigsty

Output excerpt:

{
  "duration_ms": 572,
  "events": [
    {"sequence": 0, "state": "planned",  "occurred_at": "2026-08-04T04:07:40.334787Z"},
    {"sequence": 1, "state": "staged",   "occurred_at": "2026-08-04T04:07:40.380963Z"},
    {"sequence": 2, "state": "applied",  "occurred_at": "2026-08-04T04:07:40.386186Z"},
    {"sequence": 3, "state": "built",    "occurred_at": "2026-08-04T04:07:40.904730Z"},
    {"sequence": 4, "state": "done",     "occurred_at": "2026-08-04T04:07:40.907125Z"}
  ],
  "packages": [],
  "memberships": [],
  "files": [
    {"sequence": 0, "action": "update", "phase": "pointer", "path": "dists/el9/aarch64/repodata/repomd.xml", "size": 1511, "sha256": "ef071821e06c9e86ab4f6d2a56906d82bb66df251e79d1086cfd44dc8395513e"},
    {"sequence": 1, "action": "update", "phase": "pointer", "path": "dists/el9/x86_64/repodata/repomd.xml",  "size": 1514, "sha256": "a31e90ec39169f0373b108458908333c96c5f600f3c63a50c44257856f0d2d55"}
  ]
}

The files array uses the same phase vocabulary as sow changes: payload, metadata, pointer, delete.

Build Operations also contain progress events. They keep the current state and put a versioned object in detail_json:

{
  "state": "applied",
  "detail_json": "{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}"
}

The phases are rendering, promoting_payload, publishing_dists, normalizing_public_tree, and finalizing. A progress row is durable audit data but does not advance the recovery state machine or force its own SQLite checkpoint.

Filtering by Dist

-d restricts the listing to Operations that touched that Dist — useful when one Repository serves several distributions:

sow log -d trixie -r pigsty

sow log export

Writes terminal Operations as JSONL — one complete Operation detail record per line — for archival or ingestion into a log pipeline.

sow log export /srv/audit/pigsty-ops.jsonl -r pigsty
exported 12 operations to /srv/audit/pigsty-ops.jsonl

Omit FILE, or pass -, to write to stdout:

sow log export - -r pigsty | gzip > pigsty-ops-$(date +%F).jsonl.gz
Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-d, --dist NAME Export only Operations touching this Dist all

export has no --json; JSONL is its output format.

It refuses to clobber

An existing target is a rejection, never an overwrite — an audit export must not silently destroy a previous one:

sow log export /srv/audit/pigsty-ops.jsonl -r pigsty
operation rejected: export target already exists: /srv/audit/pigsty-ops.jsonl

export also refuses a target whose parent is not a real directory — a symlink, or a directory that does not exist:

sow log export /tmp/pigsty-ops.jsonl -r pigsty
log export parent is not a real directory

On macOS /tmp is a symlink to /private/tmp, so that refusal fires there. Write to an explicit real path instead.

sow log prune

Deletes eligible terminal audit records older than BEFORE and safely compacts the database.

sow log prune 2027-01-01 -r pigsty
{"operation":"8150803833883584722","repository":"pigsty","before":"2027-01-01T00:00:00+08:00","pruned":1}

The absolute timestamp is echoed back so the local-timezone interpretation is never ambiguous.

Flag Description Default
-C, --workdir DIR Workspace discovery start directory current directory
-r, --repo NAME Select a repository selection rules
-T, --timeout DUR Maximum lock wait; 0 waits indefinitely 0
-N, --no-wait Fail immediately when the lock is held false
--json Emit the versioned JSON envelope false

prune operates at Repository level and does not accept -d — pruning half an Operation would produce a meaningless record.

BEFORE syntax

BEFORE is an ISO-8601 date YYYY-MM-DD, interpreted as local midnight, or an RFC 3339 timestamp with a timezone.

sow log prune yesterday -r pigsty
usage error: BEFORE must be YYYY-MM-DD or an RFC 3339 timestamp with timezone

What prune never deletes

prune is conservative by construction. It never removes:

  • a non-terminal Operation;
  • a record still required for recovery;
  • current Package or Membership state;
  • a Built Generation or its Changeset.

The pruned counter tells you exactly how many records were eligible, which is normally fewer than the number of Operations older than the cutoff. Log and Changeset live in the same SQLite database, but they follow different retention rules.

Examples

Investigate the most recent write:

sow log -r pgsql --json | jq -r '.result.operations[0] | "\(.id)\t\(.kind)\t\(.state)"'

List everything that failed:

sow log -r pgsql --json | jq -r '.result.operations[] | select(.state=="failed") | "\(.id)\t\(.error_class)\t\(.error_message)"'

Archive and shrink, monthly:

sow log export /srv/audit/pgsql-$(date +%Y%m).jsonl -r pgsql
sow log prune 2026-05-01 -r pgsql

Which Operation last touched a Dist:

sow log -d el9 -r pgsql --json | jq -r '.result.operations[0].id'

Exit codes

Command Code Trigger
log 0 Records printed, including an empty ledger
log 2 Usage error (including a non-numeric Operation ID), Workspace not found, or ambiguous selection
log 5 State database unreadable
log 6 The given Operation ID does not exist
log export 0 Export written
log export 1 I/O failure writing the target, or the parent is not a real directory
log export 2 Usage error or ambiguous selection
log export 6 Target already exists
log prune 0 Prune completed, including pruning nothing
log prune 2 Malformed BEFORE, -d given, or ambiguous selection
log prune 4 Repository lock held and --no-wait given or --timeout expired
log prune 5 Integrity or recovery error

See also