This is the multi-page printable view of this section. .
Features
- 1: Plain Flat Repositories
- 2: Managed Workspaces
- 3: Pool & Metadata Views
- 4: Membership Policy
- 5: Signing Model
- 6: Transactions & Recovery
- 7: Observability & Audit
SOW has two isolated execution paths. Plain mode is a stateless rebuild of one directory; Managed mode records package membership and immutable repository generations in a workspace. Neither path silently adopts state from the other.
Capability matrix
| Capability | Plain | Managed |
|---|---|---|
| RPM and DEB metadata | yes | yes |
| Mixed RPM + DEB operation | one directory | one Repository, separate Dists |
| Persistent membership and generations | no | yes |
| Per-architecture views and neutral packages | no | yes |
exclude and version limit policy |
no | yes |
| Metadata signing | no | RPM and DEB |
| RPM package signing | --sign-with |
never, fill, always |
| Transaction journal and recovery | rerun create |
workspace, Repository, publication |
| Queryable operation log and JSONL export | no | yes |
| Publication targets | no | filesystem and R2 |
SOW parses packages and renders metadata in-process. It does not invoke
createrepo_c, dpkg-scanpackages, reprepro, or modifyrepo_c. RPM package signing
is the exception: it needs the host rpm command and GPG environment because it rewrites
package payloads.
Repository formats
| Surface | RPM/YUM | DEB/APT |
|---|---|---|
| Package facts | RPM header | DEB control archive |
| Identity | NEVRA + exact-byte SHA-256 | name=version:arch + exact-byte SHA-256 |
| Indexes | primary, filelists, other, repomd.xml |
Packages, Packages.gz, Release |
| Neutral architecture | noarch |
all |
| Immutable index paths | checksum-named rpm-md | by-hash/SHA256 |
| Managed metadata signatures | repomd.xml.asc |
InRelease, Release.gpg |
SOW intentionally omits SQLite rpm-md, zchunk, modulemd, source-package indexes, and MD5/SHA1 DEB manifests. It builds repository files; it does not run an HTTP server or CDN.
Read by question
| Question | Page |
|---|---|
What does sow create write and replace? |
Plain Flat Repositories |
| How do workspaces, repositories, Dists, and private state relate? | Managed Workspaces |
| How does one pool feed many metadata-only views? | Pool & Metadata Views |
| Why was a package excluded or limited? | Membership Policy |
| Which key signs which object? | Signing Model |
| What happens after interruption? | Transactions & Recovery |
| How do I inspect, verify, and audit a repository? | Observability & Audit |
For release targets, filesystem requirements, clients, and Providers, use Platforms & Integrations.
1 - Plain Flat Repositories
sow create takes a directory that already contains .rpm and .deb files and writes flat repository indexes beside them. Plain mode has no workspace, configuration file, database, desired state, or operation journal. The package directory is the authority; every index is a disposable projection of its current contents.
That distinction is intentional. Managed repositories retain state and recover transactions. Plain repositories are cheap to recreate: if a run fails or is interrupted, run the same command again and overwrite the derived metadata.
Contract
Four rules define Plain mode:
- Packages are authoritative. Default
createdoes not modify package bytes. It replaces onlyrepodata/,Packages, andPackages.gz;--pigstyand explicit RPM signing are the documented exceptions. - One package-content pass. On the normal unsigned path, every selected package is opened once, SHA-256 hashed once, and parsed during that same pass. Complete parsed RPM/DEB metadata is retained for rendering; render and validation do not reopen package payloads.
- One cheap final check. Immediately before publication, SOW relists the top-level package set and compares
statfacts with the scan snapshot. It does not compute a second package SHA-256. - Failure means rebuild. There is no Plain transaction journal, pre-image, roll-forward, or rollback. A failure may leave partially replaced derived metadata. The next
sow creatediscards owned temporary residue and rebuilds from the package directory.
For identical input bytes, output remains deterministic and a repeat reports noop=true.
The one-pass pipeline
--jobs defaults to the logical CPU count and controls the only package-content pass:
Worker completion order never affects bytes: facts are consumed in canonical basename/index order. RPM XML is written from the parsed package object retained by the worker. DEB Packages paragraphs are written from the retained control paragraph and the SHA-256 already computed by that worker.
Output self-validation still reads generated XML, repomd.xml, Packages, and Packages.gz. Those files are small derived metadata; it does not read package bodies again.
What the final stat check proves
The final check requires:
- the same sorted set of top-level regular
.rpm/.debbasenames; - the same file identity/inode;
- unchanged file type and mode;
- unchanged size and modification time.
If any fact differs, publication is rejected with integrity exit code 5. This catches normal add, remove, replace, truncate, and rewrite races at directory-scan cost.
It is deliberately not cryptographic validation. An external writer that changes bytes in place while preserving inode, size, and mtime can evade it. Plain mode accepts that tradeoff because it targets one local cooperating writer and a rebuildable result. Use a Managed repository when hostile/concurrent mutation evidence or durable recovery is required.
Scan and output rules
- Only regular files at the directory top level are considered; scanning never recurses and never follows symlinks.
- Only
.rpmand.debsuffixes are selected. - Package identity and architecture come from the RPM header or DEB control member, never the filename. RPM
srcandnosrcare rejected. - Every valid version is indexed. Two different byte streams with the same logical coordinate are rejected.
- Default mode rejects a directory with no packages.
--pigstymay converge an interrupted cleanup whose authoritative package set is now empty.
RPM input produces repodata/; DEB input produces Packages and Packages.gz:
Flat locations are relative: RPM uses the bare basename and DEB uses ./<basename>. Public directories are mode 0755; generated files and repo_complete are 0644, independent of umask.
When one format disappears, SOW removes its known derived outputs. A rerun also replaces incomplete pairs such as a lone Packages left by an interruption, and removes SOW-shaped checksum RPM metadata no longer referenced by the new generation. Unknown files remain untouched.
Determinism and no-op
repomd.xml always uses revision and timestamps 0; gzip headers are fixed; ordering is canonical. A given package set therefore produces byte-identical metadata. Before publication SOW compares the staged metadata with live metadata. If package cleanup/signing is unnecessary and every output already matches, it removes the private stage without replacing public inodes and returns noop=true.
The JSON field recovered is always false because Plain create never performs journal recovery.
Publication and interruption semantics
All metadata is generated and validated in a same-directory private stage before publication starts. Individual file replacements use same-filesystem rename, and RPM publishes checksum-named metadata before repomd.xml.
This is not a multi-file transaction. A process killed during publication may leave new RPM metadata with old DEB metadata, one member of the DEB pair, or extra old checksum-named RPM metadata. That state is not evidence to reconcile; it is disposable output. The next run renders the complete current projection and overwrites/removes the residue.
The implementation creates no durable journal or recovery trash. On startup it discards SOW-owned residue in the reserved Plain staging namespace before starting a fresh scan.
The --pigsty marker gate
--pigsty additionally removes parsed package facts matching its compatibility rules (DEB i386 and Patroni 3.0.4) and writes repo_complete as <sha256><two spaces><basename>, sorted by basename. An RPM is not removed merely because its architecture is i386/i486/i586/i686.
Its publication order is:
A missing marker means “not complete”; consumers must not use the directory until the marker reappears. If a run stops after withdrawing the marker, rerun sow create --pigsty. The rerun scans the packages that now exist, overwrites metadata, finishes cleanup, and writes a fresh marker. No action log is required.
Default mode refuses to run while repo_complete exists, preventing an un-gated command from leaving a stale readiness claim.
Explicit RPM signing
--sign-with authorizes package mutation and is a separate slow path. SOW signs private stage copies, validates the embedded signature and signature-neutral digest, reparses the resulting RPM, then installs the signed bytes before their metadata. These necessary signing/copy/verification reads are outside the unsigned one-pass guarantee. If signing is interrupted, rerun from the package directory; no signing transaction is replayed from a journal.
Locking and scope
sow create locks the target directory and its stable parent for one run. --timeout and --no-wait control cooperative lock acquisition. The lock prevents another cooperating SOW process from writing concurrently; it does not turn arbitrary external package mutation into a supported workload.
Use Plain for a local, single-process flat-directory build that can be regenerated. Use Managed Workspaces when desired state, audit history, atomic generation switching, or evidence-driven crash recovery is part of the requirement.
Next
sow createreference — flags, output, and failure contract- Transactions & Recovery — the Managed durability boundary
- Quick Start — build one in five minutes
2 - Managed Workspaces
Managed mode is what you use when the same repository will be updated for months: packages arrive in batches, policy decides what stays, and you need to prove afterwards what changed and when. This page explains the three-tier model, the layout it produces, and how commands figure out which repository and which Dist you meant.
The three tiers
Each tier has one job, and the boundaries are strict:
Workspace owns exactly two things: the root sow.yml and the .sow/ state directory. Nothing else at the workspace root belongs to SOW. It is the unit of discovery — commands find a workspace by walking up from a starting directory — and it is where the architecture permit list lives.
Repository is fixed at <workspace>/<name>. You cannot point it somewhere else; there is no path option. A Repository owns its pool/, dists/, SQLite database, lock, recovery state, generations, retained-generation roots, publication checkpoints, and GC evidence. Two Repositories never deduplicate against each other — the same package added to both is stored twice, on purpose, so that removing one Repository can never damage the other.
Dist is an ordinary named set of memberships in exactly one format, rpm or deb. The name is an opaque string to SOW. el9, trixie, el9-beta, customer-acme — none of these create a state machine, a promotion workflow, or a snapshot. If you want a beta channel, make a Dist called el9-beta; the meaning lives in your head and your .repo files, not in SOW.
Architecture View is what build renders. It creates no second membership. A noarch RPM has exactly one package object and exactly one membership, and gets projected into every applicable view. See Pool & Metadata Views.
One Repository can hold an RPM Dist and a DEB Dist at the same time, sharing one pool/.
The layout
Managed paths are never assembled from user input. They are derived from the resolved real workspace root, a validated name, and a fixed relative fragment — which is why symlink substitution and path escape have no surface to attack.
A real workspace after two dist new and two add commands:
Everything under <repo>/ is the public delivery tree. Serve it directly, publish it with
SOW, or copy the whole tree through offline staging and an atomic switch. Everything under
.sow/ is private and must not be exposed; see the
serving guide.
Names must match [a-z0-9][a-z0-9._-]*, and ., .., .sow, pool, dists and workspace-reserved names are rejected outright.
State database and package facts
The private SQLite database indexes Desired and Built Membership by package_sha256, so
queries and builds expand a complete membership projection in bulk instead of issuing one
query per package. It also keeps a rebuildable package-facts cache keyed by immutable
package SHA-256. Ingest authenticates and parses a new package once; production builds load only
the selected digest rows in bounded, deterministic batches and lazily rebuild a missing or corrupt
row from authenticated package bytes. Unrelated or oversized facts rows are not read.
For unchanged Pool files, warm builds use device, inode, size, mtime, and ctime
fingerprints to avoid rereading payload bodies. Fingerprint drift and a missing facts row share one
authoritative SHA-256 pass and self-heal; sow check remains the explicit
full cryptographic audit and hashes every unique physical payload once regardless of fingerprints.
The cache and its fingerprints are private implementation state; they do not change the
public pool/ + dists/ layout. Never edit the database or its PRAGMA user_version by
hand. A v0.3 Repository must be backed up and explicitly upgraded with
sow repo migrate before ordinary 0.4 reads or writes.
Fresh 0.4 Repositories already use the current schema; other maintenance is performed only when a
SOW diagnostic names it.
sow.yml drives everything
There is one configuration file, parsed with a strict decoder. Unknown fields do not get ignored — they fail. So do duplicate normalized architectures, illegal names or formats, a Dist architecture that is not a subset of the workspace permit list, an invalid glob or category, and an incomplete signing block.
config show --all expands every default and normalized alias so you can see what SOW actually decided:
Architecture aliases are normalized once, at the parse boundary: amd64 → x86_64, arm64 → aarch64. Output is always the canonical family. The ecosystem names come back only in the rendered DEB view directory names (binary-amd64, binary-arm64).
config check is not a YAML linter. It opens each initialized Repository’s SQLite and compares the candidate configuration against the live Dists, architectures, memberships, built state, and signing availability. Removing an architecture family that memberships or built state still reference is an expected rejection (exit 6); corrupt database or protocol evidence is an integrity error (exit 5). Every write command runs the same preflight before it journals anything, so config check tells you in advance whether the next add would be refused.
The full schema, including filesystem and r2 publication targets, is in the
sow.yml reference.
Discovery: which workspace?
Managed commands look for the nearest ancestor sow.yml, in this order:
- If
-C/--workdir DIRis given, search upward fromDIR; this suppresses the current-directory candidate. - Otherwise search upward from the current directory.
- If that finds nothing, search upward from
$SOW_DIR, including after an explicit-Csearch fails. - Still nothing: fail, with a hint about
sow init,--workdir, andSOW_DIR.
The first sow.yml found wins; SOW does not keep climbing past it looking for a better one.
--workdir is not chdir. It changes only where discovery starts. A relative PATH argument in sow add is still resolved against your real current directory, which is what you want when you run sow add ./build/*.rpm -C /srv/ws.
sow create does not participate in any of this.
Selection: which repository, which Dist?
Repository selection, in order:
- Explicit
-r/--repo NAME. - The command’s starting directory is inside
<workspace>/<repo>/. - The workspace has exactly one Repository.
- Otherwise fail and list the candidates.
Dist selection, in order:
- One or more explicit
-d/--dist NAME(repeatable). - The starting directory is inside
<workspace>/<repo>/dists/<dist>/. - The selected Repository has exactly one Dist.
- Otherwise fail and list the candidates.
The important asymmetry: build, check, and status default to all Dists of the selected Repository when -d is absent, because operating on everything is the safe reading of “no filter”. add, rm, and ls require a definite Dist set, because guessing where a package should land is not safe:
That is exit 2. Every inference happens only after path type and symlink validation.
init converges, it does not reset
sow init is idempotent by design, and its rules are an architectural invariant rather than a convenience:
- No
sow.yml: create one withschema: sow/v3andarchitectures: [x86_64, aarch64], plus.sow/. No Repository is created automatically. - Valid config already present: in stable name order, fill in whatever is not initialized yet — a missing Repository shell, a missing SQLite, an entire missing Dist. A newly created Dist immediately gets all empty views for its effective architectures.
- Valid database state or a valid protocol pointer already present: verify only. Never overwrite, never zero a generation, never rewrite bytes.
- An architecture was added to the config after a Dist was already initialized:
initdoes not render the new view and does not advance the generation. The Dist stays dirty and waits for an explicitbuild. Removing a family that memberships or built state still use is a failure.
The point of the third and fourth rules is that init must be safe to run on a repository holding real content. It converges toward the declared configuration; it never uses “not initialized yet” as an excuse to rebuild something that already works.
Objects are processed in stable order. If an early config, Repository, or Dist commits durably and a later object then fails, the committed count is preserved, the human output reports what did commit, --json keeps the structured result, and the command exits 3 (partial success). If nothing had committed yet, it exits with the original error class instead.
Empty has a valid protocol surface
A Dist created by dist new has complete protocol entry points before you add a single
package. An RPM Dist gets valid empty repodata per architecture family; a DEB Dist gets
Packages, Packages.gz, by-hash entries, and a Release. If the Repository has a
metadata key configured, the empty Dist is signed too.
Removing the last package therefore leaves a valid signed-if-configured empty index rather than a missing or broken protocol entry point. Actual package-manager acceptance remains a separate compatibility gate.
Protected repositories
protected: true refuses repo rm even with -f, and returns exit 6. It does not restrict anything else: add, rm, build, and normal Dist maintenance all work. To actually delete the Repository you must edit sow.yml, pass config check, and only then remove it — which is precisely the friction the flag exists to create.
Next
- Pool & Metadata Views — what
buildactually writes - Publication Model — how a generation reaches a target
- Membership Policy — how
excludeandlimitdecide what stays - First Workspace — a ten-minute hands-on version of this page
sow.ymlreference — the complete schema
3 - Pool & Metadata Views
The invariant
Within one Repository, every live Package Object has one canonical payload path under
pool/. Dists and architecture views own metadata, not aliases of package bytes:
The same digest in another Repository or publication prefix is a separately owned object. SOW deliberately does not turn local deduplication into shared distributed ownership.
What a built Repository looks like
An RPM Dist with one x86_64 package and one noarch package has this shape:
There is no dists/.../pool/ subtree. Content-addressed metadata from a retained live
generation may coexist with the current files; repomd.xml is the pointer that selects
the active set.
RPM views use computed parent-relative hrefs
rpm-md resolves each package <location href> relative to the architecture view. SOW
computes the path from that view to the canonical Pool object:
The depth is derived from the actual view root, not copied from a hostname or hard-coded
deployment path. sow check resolves and normalizes each href, rejects escapes outside the
Repository, and proves that it reaches the expected Pool object.
The complete Repository root is therefore the client and delivery boundary. Point DNF at
dists/el9/x86_64/, but serve or copy the parent Repository that also contains pool/.
Why views contain metadata only
Copying each package into every architecture view would create extra object keys and uploads on storage systems without inode identity. SOW therefore gives payload ownership to the Repository Pool and lets indexes project membership. A complete copy, archive, or publication preserves that contract without depending on hardlinks.
The one-copy boundary is one Repository or one publication prefix—not a Workspace, bucket, account, or fleet. Identical packages in separate Repositories or targets retain separate owners.
Neutral packages are selected, not duplicated
An x86_64 view selects x86_64 + noarch; an aarch64 view selects aarch64 + noarch.
The neutral package remains one Pool object. Each view gets its own metadata record whose
location resolves to that same object.
DEB works the same way at the archive-root level: all packages appear in each applicable
Packages index, while Filename: pool/... points to one canonical payload.
APT views
APT already defines Filename relative to the archive root:
SOW renders Packages, Packages.gz, and by-hash entries under
dists/<dist>/main/binary-<arch>/. Release, InRelease, and Release.gpg are the
protocol pointers and signatures. There is no per-view package alias and no per-architecture
Release stub.
Ordinary clients and reposync are different contracts
The canonical layout is designed for package clients that consume the complete Repository
and honor relative protocol paths. Default EL dnf reposync has a different contract: its
safe-write check rejects a package
location that normalizes above the per-repository download directory. This is an explicit
unsupported combination; use an exported leaf for that workflow.
When a self-contained RPM leaf is required, create it outside the Repository and every configured filesystem publication root:
The export contains its own package tree, repodata, manifest, and .sow-export.json
completion marker. Copy is the default. --hardlink is an explicit same-filesystem,
trusted read-only optimization. The export is not Membership, Generation, publication
input, or a garbage-collection root.
Copy and publication
Canonical correctness does not depend on inode identity. Prefer a configured publication
target. If another transport is required, copy the complete settled pool/ + dists/ tree
with rsync, cp, or tar into an offline staging location, verify it, and switch it into
service atomically. Never update the live tree file by file. Copying only one RPM
architecture leaf is not supported because its metadata intentionally references the
sibling root Pool.
sow changes lists each payload once under pool/, followed by metadata and pointers:
There are no package-payload entries under dists/.
Next
- Managed Workspaces — ownership and Generation state
- Platforms & Integrations — tested and unsupported combinations
- Serve Repositories — HTTP, copies, and publication targets
- Repository Layout — exact public and private paths
4 - Membership Policy
Policy is the answer to “I dumped a build directory into this Dist and I do not want the debuginfo packages, and I only want the latest version of each package.” Two rules do that work, they run in a fixed order, and they run over the whole candidate set — not just the packages you happened to add this time.
The two rules and their order
exclude drops packages that match a rule. limit then caps how many versions survive per package name and architecture. The order is fixed and never configurable, because the reverse order would let an excluded package consume a version slot on its way out.
Both rules are enforced on every add, every rm, and every build. That last one matters: editing limit or exclude in sow.yml marks the affected Dists dirty, and the next build re-applies the new policy to the existing membership. You do not have to re-add anything to make a tightened policy take effect.
exclude
exclude is a list of rules. Within one rule, fields are combined with AND. Within one field, multiple patterns are combined with OR. Rules are combined with OR — any rule matching excludes the package. Field order and rule order never change the result.
That reads as: drop every debug-ish package regardless of architecture, and drop aarch64 packages whose name starts with test- or ends with -experimental.
Five fields are allowed:
| Field | Matches against |
|---|---|
name |
the binary package name |
source |
the normalized source name |
arch |
x86_64, aarch64, or neutral |
kind |
the fixed enumeration below |
format |
rpm or deb |
Patterns are case-sensitive exact strings or shell globs (*, ?, []). There is no regex, no version comparison, no negation, and no expression language. Unknown fields, empty rules, and invalid globs fail at config check rather than silently matching nothing.
kind is derived from the binary name, most specific suffix winning:
| Format | Name suffix | kind |
|---|---|---|
| RPM | -debuginfo |
debuginfo |
| RPM | -debugsource |
debugsource |
| RPM | -llvmjit |
llvmjit |
| DEB | -dbgsym |
dbgsym |
| DEB | -dbg |
dbg |
| any | none of the above | main |
Classification comes from the package itself. It never depends on which directory the file came from or which host you are running on, so the same input always classifies the same way. sow show --json exposes the computed kind.
An excluded package is reported, not treated as a parse error, and it is not stored:
The command exits 0. Nothing was wrong with the excluded package — it just does not belong in this Dist. If a package is accepted by no Dist at all, no ownerless pool object is written for it.
limit
limit groups memberships by (binary name, native architecture) and keeps the newest N:
0— keep every version. This is the default.- positive
N— keep the N newest by native version ordering. - negative — a configuration error.
Two details decide most real questions.
The grouping key includes architecture. limit: 1 does not mean “one version of this package in this Dist”; it means “one version per name and native architecture”. So pg_sample-1.13 for x86_64 and pg_sample-1.17 for noarch both survive in a limit: 1 Dist, because they are in different groups. Neutral (noarch/all) counts once as its own native architecture even though it renders into multiple views.
Ordering is native to the format. RPM uses EVR comparison — epoch, version, release, with the standard rpm segment rules. DEB uses Debian version comparison, where the version string already carries the epoch and revision. SOW does not invent a version scheme or compare strings lexically.
Here is limit: 1 deciding between two Debian versions of the same package and architecture:
Note the two levels of reporting: the item’s overall status is excluded (it did not become a member anywhere), while the per-Dist outcome is limited — telling you it lost on version, not on an exclude rule. When you have several Dists selected, each one reports its own outcome, so a package can be accepted in one and limited in another in a single command.
limit removing an older membership and adding the newer one happens inside the same operation, so the ledger shows one atomic decision rather than a delete followed by an unrelated insert.
Policy runs over the full candidate set
A common misreading is that add applies policy only to the packages on the command line. It does not. After merging your input into the target memberships, SOW evaluates exclude and then limit over the complete membership set of each selected Dist.
The practical consequence: adding version 3 to a limit: 2 Dist that already holds versions 1 and 2 removes version 1 in the same operation. You cannot smuggle a package past the version cap by adding it separately, and you never end up with N+1 members because the cap was only checked against the delta.
Loosening a policy never resurrects anything
This is the semantics people most often expect to work the other way, so it is worth showing directly. Continuing from the limit: 1 example above, remove the version that won:
The Dist is empty. The bookworm build did not come back, even though its bytes are still sitting in the pool, and even though there is now a free slot under limit: 1.
The reason is that exclude and limit remove actual Desired Memberships. SOW does not maintain a shadow list of “candidates that policy suppressed but might return later”. Pool bytes are storage, not a candidate set. Raising a limit or relaxing an exclude therefore gives you room for future additions; it does not reach back into history and guess which of the packages you once had should reappear.
To get it back, add it again — explicitly:
Convergence is one-directional and it is stated as an invariant: tightening policy can remove members; loosening policy never restores them. That asymmetry is what makes build safe to run at any time. If it were symmetric, editing sow.yml could silently republish a package you deliberately withdrew — which is exactly the failure you do not want in a security update.
sow rm removes membership, not pool bytes. The package disappears from every index, so
clients can no longer resolve it through the repository. Run sow gc only after the
payload becomes unreachable from every safety root, including current and retained
Generations, recovery state, publication attempts, and active maintenance operations.
For published targets, use sow gc TARGET; filesystem deletion is conditional and R2 is
report-only. Do not manually delete canonical pool files behind SOW’s state.
Previewing a decision
sow rm -c computes the removals, the policy consequences, and the file changes a build would produce, and writes nothing:
-c/--check takes no write lock and is mutually exclusive with --skip. Passing --timeout or --no-wait alongside it is a usage error, so that nobody mistakes a preview for something that waits on a write transaction.
Next
sow.ymlreference — the complete policy schemasow addreference — per-item statuses and the partial-success exit code- Pool & Architecture Views — where the surviving members get rendered
sow retainandsow gc— payload lifecycle controls
5 - Signing Model
There are two different questions a client can ask about a repository, and SOW answers them with two separate mechanisms. Confusing them is the most common source of “I signed it but dnf still complains”, so this page starts by pulling them apart.
Two independent trust chains
| Metadata signing | RPM package signing | |
|---|---|---|
| Question answered | “Is this index really from you, and unmodified?” | “Is this .rpm file really from you?” |
| Configured by | signing.rpm.metadata, signing.deb.metadata |
signing.rpm.packages |
| Produces | repodata/repomd.xml.asc, InRelease, Release.gpg |
an OpenPGP signature embedded in the package |
| Changes package bytes | no | yes |
| Client setting | dnf repo_gpgcheck=1, apt Signed-By |
dnf gpgcheck=1 |
| Available in Plain mode | no | yes, via create -S KEY |
They are configured separately and can be used separately. Metadata signing alone is usually the right starting point: it authenticates the whole index in one place and requires no change to the packages you received from upstream.
Managed metadata signing is controlled entirely by sow.yml. There is no CLI override, no --sign flag on build, and no way to sign one build differently from the next. That is deliberate — a repository’s signing identity is a property of the repository, not of the command that happened to update it.
Configuration
RPM and DEB metadata keys are declared separately, so you can use the same key for both (as above) or split them. Each metadata block accepts an optional passphrase reference alongside key.
With a metadata key configured, every build produces the signature files, including for an empty Dist:
- RPM, per architecture view:
repodata/repomd.xmlplus an ASCII-armoredrepodata/repomd.xml.asc - DEB, per Dist:
Releaseplus a clearsignedInReleaseand a detached armoredRelease.gpg
The clearsigned body of InRelease is identical to Release. Without a metadata key, neither signature file is generated at all — you get repomd.xml and Release and nothing else.
Four key-reference forms
A key reference is a URI, and the scheme decides who does the signing:
| Reference | Meaning | Signer |
|---|---|---|
keys/repo-signing.asc |
an ASCII-armored key at a path relative to the Workspace root | in-process Go signer |
file:///absolute/path.asc |
an ASCII-armored private key on disk | in-process Go signer |
env://VAR_NAME |
the armored key material in an environment variable | in-process Go signer |
agent://<fingerprint> |
a key held by the GPG agent in your environment | external gpg |
file:// and env:// need nothing installed — SOW signs metadata itself, which is why a repository with file:// metadata keys builds identically on macOS and inside a minimal container. agent:// delegates to your GPG agent, which is the right choice when the private key lives on a smartcard or must never touch a file. agent:// cannot be combined with a passphrase reference, because the agent owns that interaction.
A passphrase reference accepts a Workspace-relative path, file://, or env://; it
does not accept agent://.
Nothing secret is ever persisted. Configuration, SQLite, logs, JSON output, and error text hold only the reference string, the fingerprint, and the public verifier certificate. config show --all prints references and fingerprints, never key material. If a key reference is unresolvable or unusable for signing, config check says so before you run a build.
RPM package signing
Three modes:
| Mode | Behavior |
|---|---|
never |
keep the input bytes exactly as given |
fill |
sign when the package is unsigned or its signature is not trusted; keep the bytes when an existing signature verifies against trusted_keys |
always |
ensure the final package is validly signed by the configured key; keep the bytes if it already is, otherwise re-sign |
trusted_keys automatically includes the public half of the configured key. Without a key, never is the only legal mode. fill is the default when a key is present.
Trust rings are verified independently
For a signed RPM, SOW evaluates each retained single-key ring, the current policy ring, and the
combined trusted_keys ring independently. Every recognized OpenPGP signature packet must verify
inside the same candidate ring, and at least one verified path must authenticate the payload. SOW
never combines a packet accepted by one key with another packet accepted by a different single-key
ring to invent a retained signer.
This distinction matters during a deliberate dual-signing transition: the combined trusted ring may accept the package, while neither individual retained key is allowed to claim it alone. Historical CentOS OpenPGP v3/v4 signatures remain supported. All candidate rings share one signed-byte stream, so adding trusted keys changes authorization but does not multiply package reads.
Package signing always runs rpm --addsign or rpm --resign from your environment, on a
private staged copy. Your input file is never modified in place. After signing, SOW
re-parses the result and requires an embedded signature, unchanged signature-neutral
digest and NEVRA, and the exact configured public-key identity. The rpm and gpg
executables and a matching secret key in the GPG environment used by rpm are mandatory
for fill and always. A key reference identifies and verifies the signer; it does not
provision the secret key into that environment.
Because signatures embed a timestamp, signing is not reproducible — the same unsigned RPM signed twice gives different bytes. Re-adding a package you already added would therefore look like a content conflict. SOW avoids that with a signature-neutral payload digest: a SHA-256 over the immutable header and payload, excluding the RPM signature header. If the logical coordinate already exists and the neutral digest matches, and the existing object satisfies the current policy, SOW reuses the existing final bytes instead of signing again. Repeated add of the same package is a stable no-op.
That reuse is narrow on purpose. never requires a full byte match, since that mode promises to preserve input bytes. If the payload digest differs, or the stored object does not satisfy the current signing policy, it is a hard conflict — add will not quietly re-sign a package in place under an existing coordinate. There is no --replace; if re-signing changes the bytes, bump the release, or plan a proper key-rotation workflow.
Key changes make Dists dirty
A Dist’s Built configuration digest covers its format, canonical architectures, limit, exclude, and the frozen signing identity. Change a key reference or a fingerprint and the digest changes, so every affected Dist becomes dirty:
For a metadata key change, sow build signs the indexes with the new identity and
produces a new Generation.
RPM package bodies are immutable Package Objects. build does not silently re-sign an
existing object under the same coordinate. If current Desired RPMs do not satisfy the new
package-signing policy, build rejects the change. A staged rollover normally uses
fill, makes the new key current, and keeps the old public key in trusted_keys; existing
old-key packages retain their bytes while newly ingested packages use the new key. Remove
the old trust only after those package coordinates have been withdrawn or replaced by new
releases. Switching directly to always with a new key is valid only when every Desired
RPM is already signed by that key.
The exact public certificate identity of the current built metadata is recorded per Dist, and multiple certificate versions for the same primary fingerprint can coexist — so extending an expiry or adding a subkey does not invalidate what is already published.
Plain mode
Plain mode signs RPM package bodies only; it has no metadata signing. KEY is exactly 16, 40, or 64 hexadecimal characters, without an 0x prefix; it is normalized to uppercase and passed to rpm as the _gpg_name macro. Without --overwrite, only RPMs with no parseable embedded signature are signed. With it, every retained RPM is re-signed.
--sign-with requires at least one top-level RPM retained after --pigsty cleanup. A DEB-only directory, a missing rpm binary, or an unavailable key fails before anything public changes. Signing is an explicit slow path with necessary copy, signature-verification, and final-RPM parse reads; if interrupted, rerun from the current package directory rather than replaying a Plain journal. See Plain Flat Repositories.
What the client verifies
repo_gpgcheck=1 makes dnf verify repomd.xml.asc; gpgcheck=1 makes it verify each
package’s embedded signature. On the APT side, Signed-By makes apt verify InRelease.
Automated checks validate generated signatures directly. Complete signed Managed dnf/APT
acceptance must run with real clients in the target environment; see
Platforms & Integrations for the exact evidence.
sow check verifies every declared signature and file hash as part of its normal run, so a signing misconfiguration shows up before you ship rather than on a customer’s machine.
Next
- Sign Your Repository — generating a dedicated key and wiring up both chains
sow.ymlreference — the full signing schema and key reference grammar- Observability & Audit — how
checkproves the signatures
6 - Transactions & Recovery
This page explains how Managed mode prevents a live pointer from naming missing content and how it coordinates SQLite state with filesystem changes.
The invariant
On a supported local POSIX filesystem, a client following a Managed protocol pointer reads a complete old view or a complete new view, including after interruption.
Everything below exists to hold that line: metadata is fully staged and verified before anything public moves, the pointer swap is the commit decision, and every operation records enough durable evidence that the next command can finish it or undo it without guessing.
Plain sow create is intentionally outside this transaction model. Its package directory is the
authority and its metadata is a disposable projection: it performs one content pass, a final stat
check, and overwrite publication. An interruption is handled by running sow create again, not by
replaying a journal. See Plain Flat Repositories.
Note what this does not claim. dirty does not mean a half-written index — it means the
Desired state is ahead of the Built Generation while the old Built view remains complete.
SOW also does not promise that two Dists flip at the same instant; it promises that each
protocol view is self-consistent and that, when a write returns, every Dist included in
that Operation is on its recorded Built Generation.
Two durable journals
Managed lifecycle and repository mutation use two durability substrates, each with a narrow scope:
| Journal | Location | Covers | Recovered by |
|---|---|---|---|
| Workspace file journal | .sow/workspace-ops/active.json |
init, repo new, repo rm |
the next workspace-lifecycle command |
| Repository operation journal | the repository’s SQLite | dist new/rm, add, rm, build, log prune |
the next write command on that repository |
The split is not arbitrary. Workspace lifecycle operations run when the target repository’s database does not exist yet or is about to be deleted, so they cannot use it. Repository mutations have a database available and use it. Plain has neither because its recovery unit is a fresh rebuild from packages.
The workspace journal stores the operation kind, a random 64-hex id, the repository name, and both the old and new raw sow.yml bytes with their SHA-256. The workspace lock guarantees at most one active operation. The atomic rename of sow.yml is the commit decision: if the current config still hashes to the old value, the planned journal is cleaned up and rolled back; if it hashes to the new value, SOW idempotently finishes creating the repository shell or moves the removed objects into recovery. If it matches neither, SOW refuses to guess.
The repository operation journal commits a planned operation into SQLite before any public file side effect, then records each state transition. Its payload binds the repository, the config SHA-256, the exact selected Dist set, the exact build_dists, the --skip decision, and a manifest hash covering the new object facts, the complete Desired set, the per-Dist policy outcomes, the RPM public certificate snapshot, and the target generation.
This is not SQLite’s WAL. WAL handles SQLite’s own page transactions; it cannot atomically coordinate the pool, the staging area, and dists/. The application-level journal is what spans the database and the POSIX file actions.
The operation lifecycle
| State | What is durable |
|---|---|
planned |
command, arguments, targets, and intended actions |
staged |
new packages and metadata written to a private staging area and verified |
applied |
Desired state and any private pending payload committed; the public tree may still be the old generation |
built |
the complete static generation has been switched in |
done / done_dirty |
terminal; kept as the audit record |
sow log <OPERATION> shows the transitions with timestamps:
done_dirty is reachable only when you explicitly pass --skip. A default add that fails after applied returns an error, keeps the old built view serving, and leaves the operation recoverable — it does not quietly settle as dirty.
An operation that fails before applied becomes failed. This matters for a subtle case in the contract: add must record a planned operation before parsing packages, so a package with a disallowed architecture does produce an audit record. But apart from that terminal failed record, nothing is written — no package object, no membership, no pending bytes, no public tree change, no generation. You keep the audit trail without letting an invalid architecture reach any product projection.
The lock model
Locks are POSIX advisory flock on the local machine. The product contract is single-writer, local POSIX, cooperative locking — network filesystems are neither detected nor supported.
| Lock | File | Held by |
|---|---|---|
| Workspace | .sow/workspace.lock |
init, repo new/rm, dist new/rm |
| Repository | .sow/repo-locks/<repo>.lock |
add, rm, build, dist new/rm, log prune |
| Plain directory | the target directory and its stable parent | sow create |
When both are needed, the order is fixed: workspace first, then repository, released in reverse. The repository lock’s inode lives at a stable path and never moves with the private state directory, so removing a repository can withdraw the lock path while another process still holds an old descriptor, without a second writer forming on a new inode.
sow create locks the target directory and its stable parent. The parent lock is what stops another cooperating writer from replacing the directory by rename and then acquiring an independent lock on the substitute.
Read-only commands never take a write lock and do not accept lock flags. The ones that combine config, SQLite, and live metadata (config check, repo ls/show, dist ls/show) take shared locks for the duration of their snapshot. status is deliberately lighter: it probes the repository lock so it can report recovering or locked while a write is in flight, without blocking on it.
Two flags control waiting, on every command that takes a write lock:
| Flag | Behavior |
|---|---|
-T, --timeout DUR |
wait up to DUR; 0 (the default) waits forever |
-N, --no-wait |
try once and fail immediately if the lock is held |
Both failure paths exit 4. Combining --no-wait with a non-zero --timeout is a usage error, exit 2.
Use -N in cron jobs where a skipped run is better than a pile-up, and -T 30s in CI where a short queue is fine but a hang is not.
The commit order
Every generation is written in the same four phases, and the order is what makes the invariant hold:
- payload — canonical package bytes into
pool/. Nothing references them yet. - metadata — checksum-named RPM metadata,
Packages,Packages.gz, and by-hash index copies. Still nothing points at them. - pointer — the client entry points:
repomd.xml(plus.ascif configured) for RPM; for Managed APT,Release(plusInReleaseandRelease.gpg) after every per-architecture direct and by-hash index is in place. This is the commit. - delete — expired metadata from generations that have aged out.
Pending payload promotion is batched under the single writer: at most 512 objects or 1 GiB per group commit. Pool directory entries are persisted before pending names are removed, so recovery can bind a pending-only, exact dual-link, or Pool-only state back to the Operation without risking loss of both names.
Read it forward: a package always exists before an index names it, and an index always exists before a pointer names it. Read it backward: nothing is deleted until a pointer that no longer references it is durable. There is no window in which a client can follow a live pointer to a missing file.
All of this happens through a staging area on the same filesystem as the target, verified at initialization by comparing st_dev. A different mount or device is an explicit failure, never a degraded copy. Files are written, fsynced, validated by SOW’s own parser and closure validator, and only then moved in with atomic renames. Public files do not inherit your umask: repodata/ is 0755, index files and pointers are 0644.
sow changes describes the generation delta for audit and delivery planning. It is not a
safe substitute for the publication protocol: use sow publish, or copy the complete tree
into offline staging and switch it into service atomically. See
Observability & Audit.
Crash recovery
Every Managed write command recovers before it does its own work. There is no separate repair command and no daemon watching for stale state; recovery is a precondition of mutation. If a nonterminal operation exists, the next add, rm, build, dist new/rm, or log prune completes or rolls it back first, then proceeds.
Global recovery order is fixed: workspace lifecycle first under the workspace lock, then — if that was not a repository removal — repository operations in repository-name order under each stable repository lock. A workspace operation that has already passed the repository-removal commit decision takes precedence and forbids any nested repository recovery, since recovering state inside a repository that is being deleted would be meaningless.
Recovery is evidence-driven, not optimistic. Each phase has a defined rule:
| Phase reached | Recovery rule |
|---|---|
planned |
config still old → roll back the stage; otherwise conflicting evidence, exit 5 |
staged |
config still old → roll back; config already new → forward only |
applied |
the new config is atomically in place; this is the commit decision, so always forward |
built |
pointers and directories are durable; forward-commit the database rows |
done |
database, config, and tree agree; clean up staging, repeat recovery is a no-op |
This was validated by sending SIGKILL to sow add at many different moments. In every case status reported recovering, the next write command recovered that operation before executing its own, the final check passed all layers, and the public tree was never torn.
sow build is the one explicit forward-recovery entry point: it attempts to complete or roll back any decidable nonterminal operation before converging. If you see recovering, running sow build is the normal response.
error is reserved for the case where the journal, database, and file evidence contradict each other and no automatic choice is safe. Build refuses to overwrite; the last completed view keeps serving; you restore from backup and then run check and build. There is deliberately no repair --force, because a repair that guesses wrong is worse than a repair that refuses.
Fail-closed path safety
Managed paths are never assembled from user-supplied strings. Every create, rename, and delete follows the same sequence:
- resolve the workspace root to an absolute real path;
- reconstruct the target from a fixed relative fragment and verify the relative path contains no escape;
Lstatevery existing controlled component and reject symlinks and unexpected file types;- delete only objects that were first atomically moved into
.sow/.../recovery; - before deleting, prove again that the recovery target sits inside the corresponding private state directory.
Names must match [a-z0-9][a-z0-9._-]*, and ., .., .sow, pool, dists and workspace-reserved names are rejected outright.
The same posture applies to file handles. SQLite is opened with O_NOFOLLOW and bound to a regular-file inode, re-verified by path after the connection is established; a database, WAL, shm, or rollback journal that is a symlink, a non-regular file, multiply hardlinked, or rebound during the open is rejected. log export refuses to overwrite an existing file and refuses a symlinked parent directory — which is why exporting into /tmp on macOS fails, since /tmp is a symlink there.
Journals are bounded by size: 32 MiB for the workspace, 16 MiB for a repository operation payload, and 64 MiB each for the external mutation and base manifests. An oversized journal is never truncated and never degraded — it fails outside the commit window, so a writer can never produce an operation record that a recovery reader would be unable to read back.
None of this claims to defend against a malicious process running as the same user with unlimited privileges. It defends against the realistic failure modes: crashes, races between cooperating processes, and paths that changed shape between the check and the use.
Next
- Observability & Audit — reading the state these mechanisms maintain
- Exit Codes — what
4,5, and6mean and when you see them build,status,check, andchanges
7 - Observability & Audit
Each read surface answers a different question.
| Command | Question | Writes? |
|---|---|---|
status |
What state is the Repository in? | no |
check |
Does the selected Repository satisfy the complete delivery contract? | no |
changes |
What physical files differ between Built Generations? | no |
log |
Which operations and dispositions were recorded? | no |
retain ls |
Which Generations are explicit local GC roots? | no |
status: cheap state
It reports the Desired revision, Built Generation, dirty Dists, pending payload counts,
lock state, and ready_to_copy. It does not hash the public tree, recover an operation, or
build.
| Repository state | Meaning |
|---|---|
clean |
Desired and Built agree |
dirty |
Desired changed; the public tree is still the previous Built Generation |
recovering |
a durable nonterminal operation exists |
error |
durable evidence conflicts and automatic recovery cannot choose safely |
Use status to diagnose. Do not use it as a substitute for check.
check: delivery proof
In steady state, the checker reports nine ordered layers:
| Layer | Verifies |
|---|---|
config |
strict configuration and effective Dist inputs |
state |
SQLite schema and relational state |
public-modes |
expected public file and directory modes |
retained |
explicit retained Generation records and frozen metadata |
package-bytes |
pool/pending objects against recorded SHA-256 |
desired-membership |
package identity, membership, and architecture consistency |
index |
rendered metadata and reference closure |
signature |
declared metadata and RPM package trust requirements |
generation-manifest |
recorded Built manifest against the public tree |
A non-terminal layout transition has a shorter diagnostic surface: config, state,
public-modes, then layout-transition. The check stops there and returns not-ready until the
diagnosed maintenance operation completes or is safely aborted before commit.
check writes and repairs nothing. A dirty or recovering Repository is not deliverable
even if its last committed tree remains readable. Put check in the release pipeline and
stop on any nonzero exit.
Every unique physical package payload receives one authoritative SHA-256 pass per check,
regardless of cached fingerprints. Descriptor-bound evidence is then shared across retained
records, indexes, signatures, the final Generation manifest, and changes. Signed RPM verification
uses one additional shared stream for all signature packets and trust rings; it is not multiplied by
the number of Dists, keys, or retained Generations.
changes: Generation difference
- no base argument compares the current Built Generation with its predecessor;
- base
0describes the complete current public tree; - base
Ngives the net difference from recorded GenerationNto current Built.
Rows include operation, phase, Repository-relative path, size, and SHA-256. Phases use the same vocabulary as local construction: payload, metadata, pointer, delete.
changes is a manifest/difference surface. It does not contact a destination, persist a
remote checkpoint, enforce cache grace, or recover an interrupted transfer. Use
sow publish TARGET for a configured live target. For an offline copy, stage a complete
tree, verify it, and switch it into service atomically.
Generation retention and GC
retain add verifies and freezes a Generation’s metadata and reference sets; it does not
copy another package tree. The retained record is an explicit GC root. retain rm removes
that root but deletes no package bytes itself.
Local sow gc deletes only payloads proven unreachable from the current state, explicit
retentions, active recovery/publication state, and other recorded roots. Target GC is a
separate operation: sow gc TARGET uses that Provider’s safety model.
During ordinary builds, SOW carries forward the immediately preceding RPM immutable
metadata and APT by-hash objects so a reader of the previous pointer can finish. This
bounded protocol window is separate from explicit retain.
Operation log
The ledger records operation kind/state, timestamps, configuration/manifest identities, package dispositions, membership changes, and physical changes where applicable.
log export writes stable JSONL, refuses to overwrite an existing file, and validates its
output path. log prune accepts a date or RFC 3339 timestamp and removes only eligible
terminal audit records; it does not remove current state, recovery evidence, or Generation
manifests still required elsewhere.
Operational pattern
Use status for monitoring, check for the gate, publish for target mutation, and log
for later evidence.
See also
sow buildandsow check- Log commands
sow publishandsow gc