The complete configuration schema: workspace, repository, distribution, membership policy, signing, and publication targets.
This is the multi-page printable view of this section. .
Reference
- 1: sow.yml Reference
- 2: Package References
- 3: Repository Layout
- 4: Exit Codes
- 5: JSON Output
- 6: Platforms & 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 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.
Exit codes 0–6, interruption code 130, and what each 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
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 ofrepos:fails the command with exit code2and 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 --allto 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.
Top level
| 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:
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:
The list may be present or absent, but it may not be empty.
Repository
| 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:
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.
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:
See Repository Layout for why.
Dist
| 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.
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:
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 > 0keeps the newestN, comparing RPM EVR or Debian version with the native ordering rules of each ecosystem.- A negative value is a configuration error.
With limit: 1, adding an older version alongside a newer one reports it as limited and
does not create membership:
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.
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. A configured kind
outside this enumeration passes validation but matches nothing:
| 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:
Policy order is fixed: exclude runs first, then limit. An excluded package is reported
per item and is not a failure:
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.
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 bykeyor one oftrusted_keys. Packages that already verify keep their exact bytes.always— every package must end up signed bykey. 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:
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:
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:
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:
Private key and passphrase material is never printed. sow config show can resolve
key_fingerprint with or without --all. Active RPM package-signing entries also
include public-certificate snapshot digests and normalized trusted-key identities;
fingerprint and digest arrays are independently sorted/deduplicated, so they must
not be paired by index. A metadata signing entry looks like:
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.
-
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.
Publication targets
Each target binds one configured Repository to a storage namespace. Target names use the same lower-case name grammar as repositories.
| 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:
An optional temporary credential may add "session_token":"...". Unknown fields,
trailing data, missing access/secret values, and documents larger than 64 KiB are rejected.
A file:// credential must be a regular file; symlinks to one, such as mounted Kubernetes
Secrets, are fine, while FIFOs, devices, and directories are rejected without blocking.
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.
Validate it before you rely on it:
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 nopath:field. See Repository Layout. - APT components. Always
main. YUM has no component concept. - Architecture views. Derived from
architecturesand 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/rmoperation, not a rolling count in configuration.
See also
sow config— the commands that read this file- Membership Policy — how
excludeandlimitbehave over time - Signing Model — the two trust chains explained
publish,retain,gc, andexport— delivery lifecycle commands- Exit Codes — what
2and6mean here
2 - Package References
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:
Content digest
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.
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:
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
The full NEVRA, prefixed with rpm:. Every component is required, including the epoch —
0 when the package has none.
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:
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
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.
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:
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:
What this means depends on the command:
-
sow rmtreats 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 showandsow whererequire it to identify exactly one object. They describe a single package, so a name matching several is refused with the candidate list: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:
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:
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 semanticssow ls,show, andwhere— the query commands- Exit Codes —
6covers both “no match” and “ambiguous”
3 - Repository Layout
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:
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
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.5 uses internal database schema v13. Upgrading a v0.3 or v0.4 database requires a backup
and explicit sow repo migrate; v13 adds indexes so pool-path ownership checks only visit
candidate paths. Append-only publication-target binding revisions, package facts, signer
projections, and recovery evidence all remain inside <repo>.db. 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:
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
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:
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:
The export has a local pool/ and rewritten hrefs; it is a compatibility artifact, not
the canonical managed repository.
DEB views
Packages records refer to the same canonical pool from the archive root:
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:
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. A new pool path that collides case-insensitively with a known
path is rejected, even for identical bytes. On a case-insensitive workspace filesystem
(the macOS default) a source directory that differs only by case from an existing one is
rejected too; on Linux both stay distinct, so such a Repository cannot move to a
case-insensitive filesystem. See
Platforms & Integrations.
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
Every sow command uses exit codes 0–6 or interruption code 130. 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 |
130 |
Command interrupted by Ctrl-C (SIGINT) or SIGTERM |
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, discovery, config |
3 |
... batch partially succeeded |
partial |
4 |
lock unavailable: |
lock |
5 |
integrity or recovery error: |
integrity |
6 |
operation rejected: |
rejected |
130 |
varies by subsystem | interrupted |
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
CLI class prefix, such as operation rejected: or lock unavailable:. The prefix is still
present in its JSON errors[].message.
130 — Interrupted
Ctrl-C (SIGINT) or SIGTERM cancels the command and returns 130; ordinary lock-wait
timeout remains 4. Only the command’s own interruption returns 130: an internal timeout,
such as an R2 upload that makes no progress, is an ordinary runtime error (1).
SOW returns a numeric exit status; it does not re-send SIGINT to itself. A parent shell may
therefore continue to the next command unless the script checks the status, uses set -e,
or handles signals itself. Cancellation is not reported as a signing-policy failure. For an add cancelled before
Desired commits, no input is reported as accepted and the failure state is durable before
temporary package bytes are removed. If that cleanup does not finish within its 5-second
budget, the next write command (for example sow build) removes the rest, and sow check
reports recovering until then. An already committed change remains committed;
rerun the command to recover or converge it. Keep the original input packages until the
operation completes.
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.
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.
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:
Mutually exclusive options:
An ambiguous target — the repository has two distributions and the command needs one:
No workspace anywhere above the current directory — the message names where it searched and how to fix it:
A start path that names a regular file rather than a directory is refused before the search begins:
A symlink to a directory is accepted as a discovery start, including /tmp on macOS.
Discovery still walks the literal path’s ancestors and falls back to SOW_DIR
when no workspace is found. Implicit Repository and Dist selection from the start path is
stricter: any symlink component between the start directory and the workspace root prevents
that inference, so pass -r. If the symlink is inside the Repository, a command that infers the
Dist still fails with -r alone; pass -d too.
A malformed configuration file — note that the offending line is named:
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.
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:
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:
With a timeout, it fails after exactly that long:
-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:
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:
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:
A reference that matches nothing:
An ambiguous bare name, with the candidates listed so you can pick one:
An architecture the workspace does not allow. Note that the per-item message names the detected value and tells you where to change it:
A directory with nothing to index:
A --pigsty completion marker guarding an existing build:
A signing key reference that parses but does not resolve — syntax errors are 2,
resolution failures are 6:
Using them in scripts
The codes are designed so a deploy pipeline can branch without parsing text:
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
- JSON Output — the
errorsarray and itsclassfield sow check— the ordered verification layers behind code5- Transactions & Recovery — what
recoveringanderrormean
5 - JSON Output
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.
(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 | Command-specific identity, when available; see the table below. null does not imply a read-only command. |
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.
| Command | Envelope operation |
|---|---|
add, rm without --check, build |
Journal ID when one was allocated; otherwise null |
Local gc |
Journal ID when collection runs; null for a no-op |
publish, publish --abort |
Publication attempt ID when available; otherwise null |
log ID |
The requested journal ID, even though the command is read-only |
init, repo/dist/retain commands, export, target gc, rm --check, log prune, Plain create, other queries |
null; log prune places its journal ID in result.operation |
errors
| Field | Meaning |
|---|---|
code |
The process exit code — 1 through 6, or 130 for interruption. |
class |
runtime, usage, discovery, config, partial, lock, integrity, rejected, or interrupted. discovery and config specialize exit code 2; interrupted corresponds to 130 and is used only when the command itself receives SIGINT or SIGTERM. |
message |
The classified error text. It matches stderr except that Plain create prints its raw domain error to stderr, without the additional CLI category prefix. |
Branch on class, not on message text. Messages carry paths and package names and will
change; the class will not.
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 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
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:
Result shapes
create
| 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 actually signed; omitted when the list is empty, even with --sign-with |
signer |
Resolved signer identity when available; omitted otherwise |
init
On a rerun over a workspace that already exists, the counters are 0 and existing
names what was found:
config check and config show
config show returns the effective configuration itself, in the same shape as
sow.yml after normalization:
Resolved signing entries can carry key_fingerprint without --all as well.
Active RPM package-signing entries also expose key_snapshot_sha256, normalized
trusted_keys, trusted_key_fingerprints, and trusted_key_snapshot_sha256s when
available. Fingerprints and snapshot digests are independently sorted and deduplicated;
do not pair these arrays by index. These are identities of public certificates, not
private keys or passphrases.
repo ls, repo new, repo show
repo ls returns an array; repo new and repo show return one object of the same
shape.
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:
dist ls, dist new, dist show
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
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:
A failed item carries error instead of the package fields:
memberships_added and memberships_removed count both sides, because limit can evict
older versions in the same operation that admits a new one.
rm
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.
| Preview field | Meaning |
|---|---|
prediction_complete |
Present as true only when rm --check can predict the complete change set. An absent/false value does not mean the listed changes are exhaustive. |
build
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
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:
pending counts package bytes held privately after add --skip, not yet published.
repository_locked reports whether another process currently holds the write lock.
check
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:
changes
| 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 |
This is a local Generation diff, not a safe online replay protocol. Phase ordering
alone does not cover client caches, multi-file pointer commits, or deletion grace.
Use sow publish for target publication and the separate target GC protocol for
reclamation; do not immediately execute delete entries against a live repository.
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.
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:
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:
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:
Passing an operation ID returns the full detail — state transitions, structured
build_progress events, packages, memberships, and every file action:
sow log prune returns what it removed:
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:
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:
See also
- Exit Codes — the
codeandclassvalues inerrors - CLI Commands — which commands accept
--json - Observability & Audit — what the operation ledger records
6 - Platforms & Integrations
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 source module requires
Go 1.27.1 or newer. Archives include README.md, CHANGELOG.md, the Apache-2.0 LICENSE,
and THIRD_PARTY_NOTICES. Linux packages include the same license and third-party notices
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. On macOS, use APFS:
HFS+ lacks atomic directory exchange, and SOW rejects the build.
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 a new pool path that collides case-insensitively with an existing or published path,
including identical bytes added under a file name that differs only by case (add the file
under its original name). On a case-insensitive workspace filesystem, such as the macOS
default, SOW also rejects a new package whose source directory differs only by case from an
existing package’s (for example CaseDemo and casedemo), and publish to a filesystem
target on a case-insensitive volume refuses such aliases before creating an attempt. On
case-sensitive filesystems (Linux) those source directories stay distinct, so a Repository
that contains both cannot be moved to a case-insensitive filesystem.
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 PGSTY Silo container (MinIO-compatible) | Exercises bucket listing, HEAD, GET, single-part create-only/CAS PUT, replay, and object SHA-256 metadata; retries and prefix confinement are covered by local protocol tests |
| 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 current Silo integration does not validate conditional multipart completion. Multipart request/protocol tests are separate; they do not establish compatibility with hosted R2. Verify real R2 multipart behavior separately before treating that path as accepted.
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 rootpool/.Releaseadvertises SHA-256 by-hash indexes; configured signing addsInReleaseandRelease.gpg. - RPM metadata lives below
dists/<dist>/<arch>/repodata/and uses relative locations that point back to the rootpool/. 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.
Managed RPM and export rpm-leaf use a fixed 0 repomd data timestamp and expose no
--metadata-timestamp option. They cannot directly take over the same EL7 repository ID
while clients retain a positive cached timestamp; changing the base URL alone does not help.
Use a new client repository ID or clear its metadata cache. To keep the old ID and cache,
continue with Plain create and an explicit publication time, following the
migration guide and re-signing repomd.
Migrating an existing Plain YUM repository
Plain emits the primary, filelists, and other XML streams. It does not generate legacy
SQLite metadata or module streams. Ordinary RPMs can be served with an appropriate
module_hotfixes=1 policy; module profiles and dnf module install require a different workflow.
EL7 YUM compares the largest repomd.xml data timestamp with its cached copy. SOW 0.5.0
adds create --metadata-timestamp SECONDS, but its default is still 0.
An existing online repository needs a timestamp at least as large as its historical
maximum; actual updates should advance it. Updating revision or file mtime is insufficient.
The caller maintains this history and signs repomd.xml separately. Test with the same
repository URL and ID, retaining the old cache and the intended GPG checks. Follow the
migration guide for the complete procedure.
RPM dependency projection follows modern createrepo_c semantics for %pretrans and
%posttrans requirements. The 0.5 regression tests strengthen that contract without changing
the parser. Older createrepo_c versions can emit different dependency rows or pre="1"
attributes; compare dependency meaning and real client behavior rather than raw row counts.
See upstream PR #427.
RPM format v6 signatures are not supported in 0.5.0. The new signature tag can be treated as unsigned, so package signing or verification may reject such a package. Use RPM format v4 packages for signing workflows; do not interpret this limitation as a promise to preserve or verify an RPM format v6 signature.
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:
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.