Skip to content

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

Return to the regular view of this page.

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.

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

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”

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

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

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.