A managed RPM repository with per-architecture views, noarch projection, debuginfo filtering,
version limits, and a working dnf client configuration.
This is the multi-page printable view of this section. .
SOW Docs
-
1: Get Started
- 1.1: Installation
- 1.2: Quick Start
- 1.3: Your First Workspace
- 1.4: Core Concepts
- 2: Tutorials
-
3: Features
- 3.1: Plain Flat Repositories
- 3.2: Managed Workspaces
- 3.3: Pool & Metadata Views
- 3.4: Membership Policy
- 3.5: Signing Model
- 3.6: Transactions & Recovery
- 3.7: Observability & Audit
-
4: Reference
- 4.1: sow.yml Reference
- 4.2: Package References
- 4.3: Repository Layout
- 4.4: Exit Codes
- 4.5: JSON Output
- 4.6: Platforms & Integrations
-
5: Commands
- 5.1: sow create
- 5.2: sow init
- 5.3: sow config
- 5.4: sow repo
- 5.5: sow dist
- 5.6: sow add
- 5.7: sow rm
- 5.8: sow ls
- 5.9: sow show
- 5.10: sow where
- 5.11: sow status
- 5.12: sow build
- 5.13: sow check
- 5.14: sow changes
- 5.15: sow publish
- 5.16: sow retain
- 5.17: sow gc
- 5.18: sow export
- 5.19: sow log
SOW is Pigsty’s self-contained package repository manager. sow create turns the RPM and
DEB files in a directory into a usable flat repository. Managed workspaces add package
membership, policy, signing, immutable generations, audit history, and publication targets.
Press Ctrl with K (or ⌘ with K on macOS) to search this site. Press / outside an input to open command mode directly.
- Get Started — Install SOW, create a flat repository, and build the first Managed workspace.
- Tutorials — Complete YUM, APT, signing, serving, and publication walkthroughs.
- Features — Plain and Managed execution, pool projections, policy, signing, transactions, and audit.
- Design Records — Dated ownership, layout, publication, recovery, and compatibility decisions.
- Commands — Syntax, selection rules, output, state transitions, and exit behavior for every command.
- Reference — Configuration, package references, layouts, JSON, exit codes, platforms, and integration coverage.
Choose a path
| Goal | Start here |
|---|---|
| Index a package directory now | Quick Start |
| Maintain a curated long-lived repository | First Workspace |
| Build a complete YUM or APT repository | Tutorials |
| Look up exact CLI behavior | Commands |
| Check a field, path, or compatibility claim | Reference |
| Understand an architecture decision | Design Records |
1 - Get Started
SOW builds static RPM/YUM and DEB/APT repositories; it is not an HTTP daemon. Choose one of two isolated paths:
-
Plain:
sow createrebuilds indexes beside the packages in an ordinary directory. -
Managed: a workspace tracks package membership, Dists, architecture views, policy, signing, generations, audit history, and publication targets.
-
Installation — Choose a release archive, RPM/DEB package, or source build; verify the installed binary.
-
Quick Start — Create and serve a flat repository from a directory of packages.
-
First Workspace — Initialize Managed mode, create RPM and DEB Dists, add packages, build, and check.
-
Core Concepts — Workspace, Repository, Dist, Package Object, Desired Membership, and Built Generation.
Managed workspaces require a local POSIX filesystem with advisory locks, fsync, and atomic
rename semantics. Metadata generation is in-process; optional RPM package signing needs
rpm, while an agent:// metadata key needs gpg and gpg-agent.
1.1 - Installation
SOW is one executable: there is no service to enable and no runtime language environment.
Release builds target Linux and macOS on amd64 and arm64; Linux also gets RPM and DEB
packages. Windows is not supported.
Use the Download page to select the archive or Linux package that matches
your operating system and architecture. It links each published artifact, its source tag,
and SHA256SUMS.
Install an archive
Download one archive plus SHA256SUMS, then verify the matching line before extraction:
On macOS, select darwin_amd64 or darwin_arm64 and replace sha256sum -c - with
shasum -a 256 -c -. Without root, install to a directory already on your PATH, such
as ~/.local/bin.
Install a Linux package
Linux packages use the 1PGSTY release suffix:
Choose only the command and architecture that match the host. RPM installs the license at
/usr/share/licenses/sow/LICENSE; DEB installs copyright/license metadata under
/usr/share/doc/sow/.
Build from source
The module declares Go 1.27.0. Metadata generation needs no C toolchain. Replace
vX.Y.Z with the source tag linked from the Download page:
This uses the release build flags and embeds the selected tag’s product version.
Verify
sow version reports product version, target OS/architecture, and build Go toolchain.
sow help lists the command tree. Each archive also contains README.md, CHANGELOG.md,
and the Apache-2.0 LICENSE.
Upgrade a 0.3 Managed Workspace
SOW 0.4 introduces internal database schemas v11 and v12. The public layout and
schema: sow/v3 configuration identifier stay the same, but each existing v0.3 Repository must be
migrated explicitly before ordinary reads or writes. Stop Workspace writers before taking the
backup:
Repeat the last two commands for every Repository named in sow.yml. The database transition is
one-way; do not reopen a migrated Workspace with SOW 0.3. See
sow repo migrate for the repaired status, signer, and
publication evidence.
Permissions and optional tools
The invoking user needs read access to package inputs and write access to the Plain target or Managed workspace. Keep Managed workspaces on a local POSIX filesystem: locks, fsync, safe paths, and atomic rename are part of the correctness contract.
Repository parsing and metadata rendering are in-process. Only two optional paths need host tools:
- RPM package signing requires
rpmand a working GPG environment; - an
agent://metadata key requiresgpgandgpg-agent.
Next: Quick Start for Plain mode, or First Workspace for Managed mode.
1.2 - Quick Start
Plain mode builds a flat repository in one directory. It does not read sow.yml, create
a workspace, or keep a database.
Prepare a directory
Put RPM and/or DEB files at the directory top level. sow create does not recurse and
does not move or rename package files.
If one glob has no matches, copy the formats you actually have instead.
Generate metadata
An illustrative mixed-format result is:
The directory now contains:
Plain mode does not generate a DEB Release, InRelease, or Release.gpg. RPM and DEB
metadata are generated in one operation; a parse or render failure prevents the new
indexes from being committed.
Serve the directory
For a local check, any static file server is sufficient:
Verify the protocol entry points from another shell:
Python’s server is only a preview. Use a maintained HTTP server for persistent service.
Configure a client
Replace REPO_HOST with the address clients can reach.
Then refresh and install a package:
The APT source ends in ./ because this is a flat repository. [trusted=yes] and the
disabled DNF signature checks are appropriate only for this unsigned quick start. Use a
signed Managed repository when authenticity matters.
Update the repository
Change the package files and run the same command again:
The directory contents are the complete Plain-mode state. With unchanged package bytes,
the generated metadata is deterministic and a repeat run reports noop=true.
For automation, request the versioned JSON envelope:
When to use Managed mode
Use Plain mode when the directory already contains exactly what should be published. Use a Managed workspace when you need named Dists, architecture views, membership policy, signed metadata, generations, audit, or publication targets.
See also sow create and
Plain Flat Repositories.
1.3 - Your First Workspace
Managed mode keeps configuration, membership, generations, and audit state. This example starts from an empty directory.
Initialize the workspace
init creates:
Do not edit or serve .sow/. init is idempotent: rerunning it validates and converges
declared repositories and Dists; it does not reset a valid workspace.
The default architecture families are x86_64 and aarch64. Configuration accepts
amd64 and arm64 as aliases and normalizes them to those family names.
Create a Repository and two Dists
A Repository owns one public pool/ + dists/ tree and one private state database. A Dist
has exactly one format. dist new materializes a valid empty view, so empty clients receive
an empty index instead of a 404.
The public layout is now:
Add packages
Select the target Dist explicitly:
SOW reads identity and architecture from the package itself, stores accepted bytes under
local/pool/, updates Desired Membership, and builds affected Dists before returning.
The package path is only an input; later builds use the managed pool.
Use --skip to stage several membership changes without rebuilding each time, then
converge once:
While Desired Membership is ahead of the Built Generation, the Repository is dirty and
ready_to_copy=false.
Inspect and verify
status is a cheap state read. check is the delivery gate: it verifies configuration,
state, public modes, retained roots, package bytes, Desired Membership, indexes,
signatures, and the Generation manifest. It writes nothing. Only a clean Repository that
passes all layers returns success.
To see normalized configuration and defaults:
Serve the Repository
The public unit is /srv/sow/local, not the workspace root. Serve that directory at a
stable URL prefix; do not expose sow.yml or .sow/.
- DNF base URL:
https://repo.example.com/local/dists/el9/x86_64/ - APT source:
deb https://repo.example.com/local bookworm main
For a safe Nginx and filesystem-publication workflow, continue with Serve Repositories.
Selection rules
- Workspace: search upward from the current directory, or start from
-C DIR. - Repository:
-r NAME, the containing Repository, or the only configured Repository. - Dist:
-d NAME, repeatable; omission is accepted only when the command can resolve an unambiguous scope.
Ambiguity is an error; SOW does not pick an arbitrary Repository or Dist.
Next
1.4 - Core Concepts
Plain or Managed
The two execution paths are separate.
| Plain | Managed | |
|---|---|---|
| Entry point | sow create DIR |
init, repo, dist, add, rm, build |
| State | package directory | sow.yml plus private SQLite/journals |
| Public layout | flat RPM/DEB indexes | Repository pool/ + dists/ |
| Formats | RPM and DEB may share one directory | one format per Dist |
| Architecture views | no | yes |
| Policy and audit | no | yes |
| Metadata signing and publication targets | no | yes |
Choose Plain when directory contents already equal the desired repository. Choose Managed when SOW must own membership, policy, generations, signing, audit, or publication.
Managed hierarchy
- Workspace is the configuration and discovery boundary.
- Repository is the isolation, Generation, publication, and public-tree boundary. Repositories do not deduplicate payloads with one another.
- Dist is a named membership set in exactly one package format.
- Architecture view is derived output, not a second membership set.
noarchRPMs andallDEBs are selected into every applicable view without duplicating their pool bytes.
One canonical payload
A package object is identified by its exact-byte SHA-256. Its logical coordinate comes from the RPM header or DEB control data, not the filename.
Each accepted payload has one canonical path under the Repository pool/. RPM architecture
views contain repodata/ only; their package locations use parent-relative paths back to
the pool. APT Packages entries name the same pool directly.
Ordinary package clients and mirror tools are different contracts. Default dnf reposync
rejects the canonical RPM view’s parent traversal. When a self-contained RPM mirror leaf
is required, create a separate artifact with sow export rpm-leaf.
Desired and Built
Managed mode tracks intent and public bytes separately:
add and rm build affected Dists by default. --skip records membership changes but
leaves the Repository dirty; sow build later converges Desired into a new Built
Generation.
sow statusreads state cheaply and reportsready_to_copy.sow checkperforms the full, read-only delivery proof. Dirty or recovering state is not deliverable.sow changes [BASE_GENERATION]describes the physical difference between a recorded Generation and the current Built Generation. It is evidence and planning output, not a substitute for publication recovery or remote verification.sow publish TARGETpublishes a verified Generation through the configured provider and records target-scoped recovery/checkpoint state.
Transactions and failure states
Writes are serialized by Workspace or Repository locks and journal their intent before public mutation. Payloads and immutable metadata are prepared before mutable protocol pointers. A later writer recovers an interrupted operation before starting new work.
The operational states are:
| State | Meaning |
|---|---|
clean |
Desired and Built agree |
dirty |
Desired changed; Built is still the previous committed Generation |
recovering |
a nonterminal operation must be resolved |
error |
durable evidence conflicts; SOW refuses to guess |
Use status to diagnose and check as the release gate. Do not publish a Repository with
ready_to_copy=false.
Continue
2 - Tutorials
Each tutorial starts from a new workspace. Commands are intended to be run in order; replace uppercase placeholders and package paths for your environment.
If you have not installed SOW yet, start with Installation and Quick Start. The tutorials below cover the managed repository path.
A managed DEB repository with a Debian-style pool, by-hash indexes, and a deb822 client
configuration.
Generate a dedicated GPG key, sign repository metadata and RPM packages, and configure clients to reject anything unsigned.
Serve a Repository with Nginx and publish a verified Generation to a configured filesystem target without exposing private workspace state.
Turn existing dual-architecture infra-pkg RPMs and DEBs into a real repository, then rehearse local installation, rolling updates, Stable promotion, and monthly snapshots.
Which one first
| Your situation | Start here |
|---|---|
| You ship RPMs to dnf clients | Build a YUM Repository |
| You ship DEBs for Debian or Ubuntu | Build an APT Repository |
| You need signed metadata or signed RPM payloads | Sign Your Repository |
| The tree is built but nothing can reach it | Serve Repositories |
| You want to turn a dual-architecture package pool into a maintained Infra repository | Build the pigsty-infra Repository |
The YUM and APT tutorials are independent fresh-workspace paths. A real Workspace may hold both RPM and DEB Dists in one Repository when that ownership boundary suits your operation.
Conventions used here
Shell blocks contain commands without a $ prefix so you can copy a whole block at once.
Output appears in a separate block below the command, or as a comment when it is one line.
Where a command needs a value you must substitute, it appears in UPPERCASE.
Every tutorial ends with a verification step. sow check returning 0 proves the selected
Repository is complete and matches the recorded Generation. A nonzero result is not a release
artifact.
2.1 - Build a YUM Repository
This tutorial creates a new Managed RPM repository. You need a writable directory, and one or more RPM files.
1. Create the workspace
The Dist name is an identifier chosen by you. SOW does not infer an operating-system
release from el9.
2. Set membership policy
Edit the generated sow.yml. This example keeps one version per package and architecture
and excludes debug packages:
Validate every manual edit before writing repository state:
exclude runs before limit. Neutral noarch packages are projected into every enabled
architecture view; they are not listed in architectures.
3. Add RPMs
add parses the package headers, stores each accepted package once in the canonical pool,
updates Desired membership, and materializes a new Generation. Excluded inputs are reported
per item and are not command failures.
The public tree has this shape:
The rpm-md location href entries reach package bytes in the root pool/ by relative
paths. Do not copy an architecture directory by itself: it is not a standalone repository.
4. Preview over HTTP
For a local preview:
Check the entry point from another shell:
Use a maintained HTTP server for persistent service. It must expose the whole pigsty/
tree so client-resolved package URLs under pigsty/pool/ remain reachable.
5. Configure dnf
Replace REPO_HOST with an address the client can reach:
Then refresh and query the repository:
This configuration is deliberately unsigned. Enable client verification only after following Sign Your Repository.
6. Publish or export
Before delivery, require a successful deep check:
Use sow publish for a configured filesystem or R2 target. A
whole-root copy is acceptable only into an offline staging location that is switched into
service atomically; do not run an unordered in-place sync against a live repository.
Some mirroring tools, including default dnf reposync, reject rpm-md package locations
that traverse to the root pool. Export a self-contained RPM leaf when such a consumer is
required:
The destination must be absent or empty. The export duplicates package bytes by default;
--hardlink is an explicit same-filesystem, trusted, read-only optimization.
Update the repository
add and rm change Desired membership. build is useful after policy or signing
configuration changes. check is the publication gate; status alone is not.
The automated client and platform scope is listed under Platforms & Integrations.
2.2 - Build an APT Repository
This tutorial creates a new Managed DEB repository. You need a writable directory, and one or more DEB files.
1. Create the workspace
The Dist name becomes the APT suite. It is an identifier chosen by you; SOW does not infer
distribution semantics from trixie.
2. Set membership policy
Edit the generated sow.yml if you need filtering or version limits:
Then validate it:
SOW stores canonical architecture families in configuration and renders Debian names in
the repository: x86_64 becomes amd64, aarch64 becomes arm64, and neutral all
packages are included in both views.
3. Add DEBs
Accepted package bytes are stored once. The public tree is:
Package paths beneath pool/ are grouped by normalized source package. Packages uses
archive-root-relative Filename values. SOW writes SHA-256 by-hash copies and advertises
them from Release.
4. Preview over HTTP
For a local preview:
Check the entry points:
Use a maintained HTTP server for persistent service and expose the complete pigsty/
tree.
5. Configure APT
Replace REPO_HOST with an address the client can reach. For an unsigned test repository,
use a deb822 source with explicit trust:
Then refresh and query it:
Trusted: yes disables authenticity checking and is suitable only for a controlled test.
For a signed repository, remove that line and configure a keyring:
Follow Sign Your Repository before enabling Signed-By.
6. Publish safely
Require a successful deep check before delivery:
Use sow publish for a configured filesystem or R2 target. If
you use another transport, copy the entire repository into an offline staging location and
switch it into service atomically. Do not update a live dists/ tree file by file: clients
may observe metadata and package state from different generations.
Update the repository
Use build after policy or signing configuration changes. Use check, not status alone,
as the publication gate.
The automated client and platform scope is listed under Platforms & Integrations.
2.3 - Sign Your Repository
SOW has two independent signing paths:
| Path | Output | Client control |
|---|---|---|
| RPM metadata | repodata/repomd.xml.asc |
repo_gpgcheck=1 |
| APT metadata | InRelease and Release.gpg |
Signed-By |
| RPM package body | embedded RPM signature | gpgcheck=1 |
APT trusts package hashes through the signed Release; SOW does not re-sign DEB package
bodies. Start with metadata signing. Add RPM package signing only when you own the signing
policy for those package bytes.
1. Create a dedicated key
The commands below create an unencrypted example key. Use a protected key and a
passphrase reference for production; see the configuration reference.
Keep the secret key outside the Workspace’s public Repository tree and outside every web
root. If a dedicated service account runs SOW, make that account—not the interactive
user—the directory owner. Distribute only repo-signing.pub to clients.
2. Configure metadata signing
In /srv/sow/sow.yml, add the relevant blocks under the Repository. Omit the ecosystem
you do not use:
Validate the key reference, rebuild, and run the publication gate:
For a protected key, add passphrase: env://SOW_METADATA_PASSPHRASE or a bounded file
reference next to key. SOW never writes key or passphrase material into configuration,
SQLite, JSON, or logs.
3. Verify metadata manually
Use the exact paths for your Dists and architectures:
sow check verifies the configured signing identity as part of its deeper consistency
checks. Manual verification is still useful when establishing a client trust root.
4. Optional: sign RPM package bodies
Add rpm.packages only if clients require embedded package signatures:
Replace the placeholder with the 40-hex fingerprint printed in $FPR. For this operation:
rpmandgpgmust be installed;- the matching secret key must be available in the ambient GPG environment used by
rpm; fillpreserves packages already signed by the configured ortrusted_keysidentities;alwaysre-signs everything not already signed by the configured identity;neverleaves input bytes unchanged.
SOW invokes rpm --addsign or rpm --resign on a private staged copy, not on the input
file. Revalidate and rebuild after changing the policy:
Inspect a resulting package with rpmkeys --checksig /path/to/package.rpm.
5. Enable dnf verification
Transfer the public key to the client through a trusted channel:
Then enable the checks that correspond to what you signed:
Set gpgcheck=0 if package-body signing is not configured. Do not disable
repo_gpgcheck after configuring metadata signing.
6. Enable APT verification
Install the public key as a dedicated keyring:
Reference it from deb822 configuration and do not set Trusted: yes:
Run apt update and treat any signature error as a failed deployment, not as a reason to
weaken the client configuration.
Plain-mode RPM signing
Plain mode can sign RPM package bodies, but it does not sign repository metadata or create
an APT Release:
The key must be exactly 16, 40, or 64 hexadecimal characters, with no 0x prefix, and the
matching private key must be usable by the ambient rpm/GPG setup. Without --overwrite,
already signed RPMs keep their bytes; adding --overwrite explicitly re-signs every RPM.
SOW signs private staged copies before replacing package bytes and metadata.
Key changes
Changing a key reference or resolved fingerprint marks affected Dists dirty. A metadata
key can be changed by distributing the new public key, rebuilding, checking, and then
switching client enforcement. RPM package keys need a staged rollover: Package Objects
are immutable, and build rejects stored RPMs that do not satisfy the new policy instead
of re-signing them in place. Use fill with the old public key in trusted_keys until old
package coordinates have been withdrawn or replaced. Finish with a real client acceptance
test in the target environment.
Run the final signed repository through the exact dnf/APT versions and trust policy used in production. The automated scope is listed under Platforms & Integrations.
2.4 - Serve and Publish Repositories
SOW writes static files; it is not an HTTP server. This guide keeps the writable Workspace separate from the path Nginx serves.
Public and private paths
| Mode | Public unit | Never serve |
|---|---|---|
| Plain | the directory passed to sow create |
transient .sow-plain-stage-* output; no durable journal |
| Managed | one Repository’s complete pool/ + dists/ tree |
Workspace sow.yml, .sow/, SQLite, locks, journals, staging |
For the workspace in First Workspace, the source Repository is
/srv/sow/local. Do not make /srv/sow the document root.
1. Gate the source Generation
Continue only when check returns 0. status is useful for diagnosis, but check is
the full read-only delivery proof.
2. Configure a filesystem target
Create the endpoint directory first. It must be a real, canonical directory, not a symlink; SOW refuses to create a missing endpoint for you.
The second command gives the current operator write access; use the account that will run
sow publish if publication runs under a dedicated service user.
Add a target to /srv/sow/sow.yml:
The three booleans are mandatory safety acknowledgements. The endpoint and prefix combine
to /srv/repo-public/local; SOW creates and owns the prefix below the pre-existing endpoint.
Validate and publish:
Publication copies immutable payloads and metadata before mutable protocol pointers, verifies the result, and records a target checkpoint. Repeating the command for an unchanged Generation is an idempotent no-op.
Do not let another tool write into the same target prefix. The target contract is single-writer and exclusive.
3. Serve the target with Nginx
Reload Nginx after validating its configuration. The client URLs are:
If metadata or packages are signed, publish the corresponding public key separately and
configure gpgkey/Signed-By; private keys never belong under the document root.
4. Verify the served entry points
Then run the actual package manager from a client host. HTTP reachability is not package client verification; both checks matter.
The complete Repository prefix must have one access policy. RPM metadata may resolve a
package through ../../../pool/..., and APT Filename fields point to pool/... directly.
Protecting dists/ while accidentally exposing or blocking pool/ breaks the repository.
Manual and air-gapped delivery
If sow publish cannot reach the destination:
- run
sow checkon the source; - copy the complete Repository into a new, non-live staging or release directory;
- verify transport checksums against
sow changes 0or an archive manifest; - atomically switch an operator-owned parent reference to the new directory;
- keep the previous release until clients and caches have moved past it.
Do not run an unordered rsync --delete directly against a live Repository root. That
does not preserve SOW’s pointer ordering, target checkpoint, cache grace, or recovery state.
sow changes describes Generation differences; it is not an authorization to mutate a
live target without those controls.
R2 targets
provider: r2 uses an S3-compatible storage transport and report-only target GC. The
transport integration covers list, HEAD, GET, and conditional PUT against a pinned MinIO
fixture. Validate credentials, bucket policy, public endpoint, cache behavior, replay, and
recovery on a nonproduction prefix before enabling a production target. See
Platforms & Integrations.
Next
2.5 - Build the pigsty-infra Repository
pgsty/infra-pkg is the upstream build source for Pigsty Infra packages.
This tutorial assumes the dual-architecture RPMs and DEBs already exist and covers the second half of the job: turning that pile of packages into a real, consumable, maintainable SOW repository named infra.
1. Put the packages under ~/repo
This tutorial uses the fixed path ~/repo throughout. Copy the existing packages into two input directories:
Confirm that all four format-by-architecture cells contain real payloads:
All four results must be greater than zero. At this point, the tree is only an input package pool:
2. Create the infra Repository and two Dists
Initialize a Workspace and create the Repository named infra:
The model is now fixed:
Open ~/repo/sow.yml and reduce it to this configuration:
limit: 1 keeps only the newest version for each package name and native architecture. The rpm and deb
Dists are therefore rolling latest channels while still retaining both x86-64 and ARM64.
3. Ingest once and build once
Update Desired Membership first, then build a single time:
Initialization is complete only when sow check returns 0. Verify the formats and architectures that SOW
read from the package headers:
The output must include at least:
SOW reports canonical architecture names, so DEB amd64/arm64 appear here as x86_64/aarch64.
4. Read the generated filesystem tree
Print the actual directories:
The important structure is:
One path rule is easy to miss and essential to remember: SOW always places Dists below dists/. The logical
infra/rpm and infra/deb channels are therefore served from /infra/dists/rpm/ and /infra/dists/deb/,
while package payloads live in /infra/pool/. Always publish or mount the complete ~/repo/infra tree, never
an individual Dist.
5. Serve the repository read-only with Nginx
Use the official nginx:alpine image and mount the Repository Root read-only at /infra:
Check both metadata entry points directly:
Nginx can see only ~/repo/infra; it cannot see sow.yml or .sow/, and it cannot modify the repository.
6. Install an RPM from only infra on EL9
The Rocky Linux 9 container below is attached to the --internal network. The script removes every preconfigured
repository and enables only infra, so a successful installation cannot fall back to a public mirror.
An RPM baseurl must point to a concrete architecture view. dnf expands $basearch to x86_64 or aarch64.
7. Install a DEB from only infra on Ubuntu 24.04
APT points URIs at the Repository Root and uses the Dist name deb as Suites:
This isolated HTTP lab temporarily disables verification. A production service should sign RPM and APT metadata
and remove both gpgcheck=0 and Trusted: yes.
Docker validates the host architecture by default. To run the complete four-cell matrix, repeat each docker run
once with --platform linux/amd64 and once with --platform linux/arm64; cross-architecture execution requires
Docker binfmt/QEMU support. Repository inventory and client installation are separate acceptance gates.
8. Routine maintenance: add a new version
The normal update operation is add, not removing the old package first. Suppose the four new pg-exporter
payloads are ready:
Because the latest Dists have limit: 1, the new version wins and the old version automatically leaves that
Dist’s Desired Membership. The old bytes are not deleted immediately, and relaxing policy later does not make
the old membership reappear automatically.
Rerun the EL9 and Ubuntu clients from sections 6 and 7, refresh metadata, then install or upgrade to complete the update acceptance test.
Do not begin a normal release with sow rm. If a bad package must be withdrawn, use
sow ls -r infra -d rpm --json (or the corresponding -d deb) to find its exact SHA-256, run
sow rm sha256:... -r infra -d rpm --check, inspect the plan, then run the same command without
--check. rm removes only Dist Membership; conservative sow gc handles pool bytes separately. Avoid a bare
package name that could remove every version and architecture.
9. Two retention layers: latest and stable
limit: 1 makes rpm and deb good rolling channels but cannot express “keep every formally released version.”
Create two more Dists for that purpose:
The new Dists default to limit: 0, which means retain every version. The resulting policy is:
| Dist | Format | limit |
Role |
|---|---|---|---|
rpm |
RPM | 1 | RPM latest |
deb |
DEB | 1 | DEB latest |
rpm-stable |
RPM | 0 | Accumulate promoted RPMs |
deb-stable |
DEB | 0 | Accumulate promoted DEBs |
Stable does not automatically resurrect every historical object that happens to remain in the pool. It starts accumulating versions that you explicitly promote from this point forward.
10. Promote latest into stable
SOW does not expose a dedicated promote command. The reliable current procedure is to freeze writers,
export the exact source Dist Membership, and add those objects to the target Dist. Inputs come directly from
infra/pool; SOW verifies and reuses each existing Package Object without repackaging it or storing a second copy.
First require clean source state and save the promotion manifests:
Pause writes to rpm and deb until promotion finishes, then reuse the pool objects:
Each add should report reused. If the loop stops midway, the source Dists are unchanged; correct the failure
and rerun the same manifest. Over subsequent promotions, rpm/deb retain only the latest version while
rpm-stable/deb-stable accumulate release history.
11. Take the 2026-08 snapshot from stable
A client-visible monthly snapshot is another pair of Dists:
Pause stable writes during the snapshot window and first persist its exact Membership as manifests:
Add those manifests to the corresponding snapshot Dists:
Also retain the complete verified Repository Generation so later GC treats it as a safety root:
retain protects a whole Repository Generation for recovery and GC; the fixed client-visible URLs are still
provided by the rpm-202608 and deb-202608 Dists. SOW does not enforce Dist immutability, so never running
add or rm against snapshot Dists after creation is part of the operating contract.
12. Client address map
One Nginx service and one shared infra/pool support every channel:
| Channel | dnf baseurl |
APT URIs / Suites |
|---|---|---|
| latest | http://infra-nginx/infra/dists/rpm/$basearch/ |
http://infra-nginx/infra / deb |
| stable | http://infra-nginx/infra/dists/rpm-stable/$basearch/ |
http://infra-nginx/infra / deb-stable |
| 2026-08 | http://infra-nginx/infra/dists/rpm-202608/$basearch/ |
http://infra-nginx/infra / deb-202608 |
Run the final acceptance checks:
The result is not a disposable demo directory. It is a real Infra Repository that can continue ingesting packages,
promoting releases, and producing monthly snapshots: rpm/deb move quickly, rpm-stable/deb-stable accumulate
formal history, monthly Dists provide fixed endpoints, and every view reuses the same immutable package objects.
Stop the temporary service when the lab is complete:
3 - Features
SOW has two isolated execution paths. Plain mode is a stateless rebuild of one directory; Managed mode records package membership and immutable repository generations in a workspace. Neither path silently adopts state from the other.
Capability matrix
| Capability | Plain | Managed |
|---|---|---|
| RPM and DEB metadata | yes | yes |
| Mixed RPM + DEB operation | one directory | one Repository, separate Dists |
| Persistent membership and generations | no | yes |
| Per-architecture views and neutral packages | no | yes |
exclude and version limit policy |
no | yes |
| Metadata signing | no | RPM and DEB |
| RPM package signing | --sign-with |
never, fill, always |
| Transaction journal and recovery | rerun create |
workspace, Repository, publication |
| Queryable operation log and JSONL export | no | yes |
| Publication targets | no | filesystem and R2 |
SOW parses packages and renders metadata in-process. It does not invoke
createrepo_c, dpkg-scanpackages, reprepro, or modifyrepo_c. RPM package signing
is the exception: it needs the host rpm command and GPG environment because it rewrites
package payloads.
Repository formats
| Surface | RPM/YUM | DEB/APT |
|---|---|---|
| Package facts | RPM header | DEB control archive |
| Identity | NEVRA + exact-byte SHA-256 | name=version:arch + exact-byte SHA-256 |
| Indexes | primary, filelists, other, repomd.xml |
Packages, Packages.gz, Release |
| Neutral architecture | noarch |
all |
| Immutable index paths | checksum-named rpm-md | by-hash/SHA256 |
| Managed metadata signatures | repomd.xml.asc |
InRelease, Release.gpg |
SOW intentionally omits SQLite rpm-md, zchunk, modulemd, source-package indexes, and MD5/SHA1 DEB manifests. It builds repository files; it does not run an HTTP server or CDN.
Read by question
| Question | Page |
|---|---|
What does sow create write and replace? |
Plain Flat Repositories |
| How do workspaces, repositories, Dists, and private state relate? | Managed Workspaces |
| How does one pool feed many metadata-only views? | Pool & Metadata Views |
| Why was a package excluded or limited? | Membership Policy |
| Which key signs which object? | Signing Model |
| What happens after interruption? | Transactions & Recovery |
| How do I inspect, verify, and audit a repository? | Observability & Audit |
For release targets, filesystem requirements, clients, and Providers, use Platforms & Integrations.
3.1 - Plain Flat Repositories
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
3.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.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
3.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
3.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
3.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
3.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
4 - Reference
This section is the stable contract for configuration fields, package references, paths, exit codes, JSON, platforms, and integrations. CLI syntax and state transitions live in Commands; use Get Started for the operating model.
Output examples show shape; identifiers, paths, hashes, timestamps, and counts vary by
workspace. The built-in sow help remains the exact syntax authority.
The complete configuration schema: workspace, repository, distribution, membership policy, signing, and publication targets.
The five ways to name a package on the command line, how ambiguity is resolved, and
which forms rm, show, and where accept.
Every path SOW creates in plain and managed mode, the pool grouping rule, name constraints, and which directories must never be exposed over HTTP.
The seven exit codes and what each one means.
The sow.cli/v1 envelope, the meaning of each top-level field, and result shapes for the
primary command families.
Release targets, filesystem requirements, repository-client checks, publication Providers, and the exact scope of each automated integration.
Conventions
Command examples are written without a $ prompt so you can copy a whole block. Output
blocks are representative; variable values and long structures may be shortened where
marked. The built-in sow help remains the exact syntax authority shipped with a binary.
Placeholders in syntax blocks are uppercase (NAME, DIR, PACKAGE); literal text is
lowercase. Square brackets mark optional arguments, ... marks a repeatable one, and a
vertical bar separates alternatives — the same convention sow help uses.
4.1 - sow.yml Reference
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:
| 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:
Secret material never leaves the reference. sow config show --all prints the reference
and the resolved fingerprint, and nothing else:
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.
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
4.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”
4.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.4 uses internal database schema v12. Its append-only publication-target binding revisions,
package facts, signer projections, and recovery evidence all remain inside <repo>.db. A v0.3
database must be backed up and upgraded with sow repo migrate; never edit PRAGMA user_version
or copy the database without its matching public/private Repository state.
Canonical pool
Each package payload has one canonical path:
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. Case-insensitive pool-path collisions are rejected so output
remains portable between Linux and default macOS filesystems.
Point your web server at <workspace>/<repo>/, not at the workspace root. The public
repository needs both pool/ and dists/; the private .sow/ directory must stay hidden.
See also
4.4 - Exit Codes
Every sow command exits with one of seven codes. They are stable, they are the same for
every command, and they are meant to be branched on in scripts — the distinction between
“this failed” and “this was correctly refused” is the whole point of having more than one
nonzero code.
| Code | Meaning |
|---|---|
0 |
Complete success, or an idempotent no-op |
1 |
Runtime I/O, parser, renderer, or unknown internal error |
2 |
Usage, workspace discovery, or configuration error |
3 |
Partial success: at least one item committed, at least one failed |
4 |
Write lock unavailable — held and --no-wait, or the timeout expired |
5 |
Integrity or recovery error, or check judged the result not deliverable |
6 |
Expected rejection: conflict, protected, no match, incompatible architecture |
Human-readable results go to stdout; warnings and diagnostics go to stderr. Each code has
a stable message prefix on stderr, and a matching class in
JSON output:
| Code | stderr prefix | JSON class |
|---|---|---|
1 |
varies by subsystem | runtime |
2 |
usage error: / workspace discovery error: / configuration error: |
usage |
3 |
... batch partially succeeded |
partial |
4 |
lock unavailable: |
lock |
5 |
integrity or recovery error: |
integrity |
6 |
operation rejected: |
rejected |
sow create is the exception to the prefix column: being outside the managed layer, it
prints its raw domain error on stderr (plain: scan …, plain: marker gate …) without the
operation rejected: prefix. The prefix is still present in its JSON errors[].message.
0 — Success or no-op
The command did what you asked, or found there was nothing to do. Both are success:
re-running sow create over an unchanged directory, or sow build on a clean repository,
exits 0 and says so.
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 directory that is not a real directory — a symlink, for instance, which is what
/tmp is on macOS — is refused before the search even begins:
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
4.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 | The operation ID for write commands, null for read-only ones. |
result |
object or null | Command-specific payload, described below. |
errors |
array | Zero or more {code, class, message} objects. |
All seven fields are always present. result is null when the command failed before meaningful
work — an unknown flag, discovery failure, or invalid configuration before a Repository was
selected, for example. Committed partial results and diagnostic results from check or
rm --check are preserved even when the command exits nonzero.
errors
| Field | Meaning |
|---|---|
code |
The process exit code — 1 through 6. |
class |
runtime, usage, discovery, config, partial, lock, integrity, or rejected. discovery and config are stable specializations of exit code 2. |
message |
The same text written to stderr. |
Branch on class, not on message text. Messages carry paths and package names and will
change; the class will not.
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 re-signed; present only with --sign-with |
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:
With --all, signing entries additionally carry key_fingerprint. Private key material
and passphrases never appear.
repo ls, repo new, repo show
repo ls returns an array; repo new and repo show return one object of the same
shape.
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.
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 |
Apply the phases in that order and no client ever sees a dangling reference: package
bytes first, then checksum-named metadata, then the protocol pointers (repomd.xml,
Release), and only then the deletion of superseded files.
sow changes 0 yields the complete current tree as one add set — a full delivery
manifest.
ls, show, where
ls returns an array of package objects; show returns exactly one under package.
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
4.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 0.4.0 artifacts were
built with Go 1.27.0; the source module requires Go 1.27.0 or newer. Archives include README.md,
CHANGELOG.md, and the Apache-2.0 LICENSE;
Linux packages install the same license with the binary. Use sow version
to print the product version, target OS/architecture, and build toolchain.
Workspace filesystem
Managed workspaces belong on a local POSIX filesystem. Correctness depends on advisory
locks, fsync, descriptor-bound path checks, and atomic same-filesystem rename. NFS and
other network filesystems are not supported workspace locations.
The public <workspace>/<repo>/ tree is different: it is a closed pool/ + dists/
namespace designed for whole-root copying and publication. It does not depend on SQLite,
private journals, or view-local hard-link identity. Keep the complete Repository together
and never expose .sow/.
SOW rejects symlinked control paths, unsafe regular files, overlapping filesystem targets, and case-folded pool-path collisions. This keeps one Repository portable between case-sensitive Linux filesystems and the default case-insensitive macOS setup.
Automated integration matrix
| Surface | Environment | Verified behavior |
|---|---|---|
| Production CLI clean room | Linux CI | Builds the shipping binary; creates mixed Plain RPM/DEB metadata; initializes sow/v3; creates RPM and DEB Dists; adds fixtures; runs query, build, check, changes, config, and log commands |
| Plain APT client | Ubuntu 22.04 container | Serves sow create output over HTTP; runs apt-get update, package discovery, exact-version selection, download, and install with an explicitly trusted unsigned source |
| RPM detached-signature transition | AlmaLinux 8, 9, and 10 containers | Runs real DNF clients against serial repomd.xml / repomd.xml.asc transition states and pins which combinations succeed or fail |
| S3-compatible transport | Pinned MinIO container | Exercises bucket listing, HEAD, GET, create-only/CAS writes, replay, conditional multipart upload, object metadata, retries, and prefix confinement |
| Release packaging | Linux CI | Builds four archives, two RPMs, two DEBs, and SHA256SUMS; checks package paths, Apache-2.0 metadata, and packaged license bytes |
The DNF signature-transition probe is a protocol test, not a complete Managed RPM install. The APT job covers an unsigned Plain repository, not Managed metadata signing. Run the exact dnf/APT version, repository URL, access policy, and signing policy used by your deployment before promoting it.
Repository client contract
Plain RPM repositories expose repodata/ beside package files. Plain DEB repositories
expose Packages and Packages.gz beside package files. They can be consumed through
file:// or HTTP after the client trust policy is configured.
Managed clients consume the complete Repository root:
- APT indexes live below
dists/<dist>/main/binary-<arch>/and refer to the 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.
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.
5 - Commands
Each top-level command has its own page. Command groups
such as config, repo, dist, retain, export, and log document their subcommands together.
The built-in sow help output is the syntax authority. These pages add selection rules, state
transitions, output contracts, failure behavior, and practical examples.
Command index
sow create is the Plain-mode repository command and operates directly on a directory. sow init
bootstraps Managed mode and may create sow.yml; the remaining stateful commands discover an
existing Workspace. help and version are utility commands and need neither mode.
| Command | Mode | Purpose |
|---|---|---|
sow create [DIR] |
Plain | Generate a flat RPM/DEB repository in place |
sow init [DIR] |
Managed | Initialize a Workspace and converge declared Repositories/Dists |
sow config check|show |
Managed | Validate or print the effective configuration |
sow repo ls|new|show|migrate|rm |
Managed | Manage Repositories; migrate is specialized maintenance |
sow dist ls|new|show|rm |
Managed | Manage Dists |
sow add PATH... |
Managed | Add packages to Desired Membership |
sow rm PACKAGE... |
Managed | Remove packages from Desired Membership |
sow ls |
Managed | List Desired and Built Membership |
sow show PACKAGE |
Managed | Inspect one Package Object |
sow where PACKAGE |
Managed | Locate a Package Object across the Workspace |
sow status |
Managed | Read Repository state without deep verification |
sow build |
Managed | Converge Desired state into a Built Generation |
sow check |
Managed | Verify configuration, state, bytes, views, signatures, and manifest |
sow changes [BASE_GENERATION] |
Managed | Diff Built Generations as a file delivery plan |
sow publish TARGET |
Managed | Publish a verified Generation to a configured target |
sow retain add|ls|rm |
Managed | Manage explicit retained-Generation roots |
sow gc [TARGET] |
Managed | Collect unreachable local payloads or maintain a publication target |
sow export rpm-leaf |
Managed | Build a standalone RPM compatibility leaf |
sow log [OPERATION] |
Managed | Read, export, and prune the Operation audit ledger |
Global syntax
Running sow with no arguments prints the command list and exits 0. Use sow help COMMAND or
sow help COMMAND SUBCOMMAND for built-in usage. sow version and sow --version print the binary
identity.
There is no global --format, --yes, --dry-run, -q, -v, or --config. Unknown flags are
usage errors.
Workspace discovery
Managed commands find the nearest ancestor containing sow.yml:
- Start at
-C/--workdir DIR, when supplied; otherwise start at the current directory. - Search upward and stop at the first
sow.yml. - If that search finds nothing, repeat from
SOW_DIRwhen set. An explicit-Csuppresses the current-directory candidate, but not theSOW_DIRfallback. - If no Workspace is found, exit
2.
--workdir changes only the discovery start directory. It does not change the process working
directory, so relative positional paths still resolve against the actual current directory.
sow create never performs Workspace discovery.
Repository selection
Commands that require one Repository select it in this order:
- explicit
-r/--repo NAME; - the Repository containing the discovery start directory;
- the only Repository in the Workspace;
- otherwise fail with exit
2and list the candidates.
repo new and repo rm take NAME positionally and do not accept -r. sow where searches all
Repositories by default; -r narrows its scope. Publication targets select their configured
Repository, so publish TARGET and gc TARGET do not accept an additional Repository selection.
Dist selection
add, rm, and ls require a concrete Dist set and select it in this order:
- repeated
-d/--dist NAMEvalues; - the Dist containing the discovery start directory;
- the only Dist in the selected Repository;
- otherwise fail with exit
2and list the candidates.
Other commands deliberately differ:
build,check, andstatusdefault to all Dists when-dis absent;showsearches the whole selected Repository unless-dnarrows it;wheresearches all matching Dists across the Workspace unless-r/-dnarrow it;changesis Repository-wide and rejects-d.
Locking
Write commands other than init accept -T/--timeout DUR and -N/--no-wait. init takes the
Workspace lock and waits without a CLI timeout override. Other locks are Repository-scoped except
for repo new and repo rm, which also use the Workspace lock. --timeout 0 waits indefinitely;
a positive timeout uses Go duration syntax such as 500ms, 30s, or 5m. --no-wait fails
immediately. A positive timeout and --no-wait are mutually exclusive. Lock acquisition failure
exits 4.
Read-only commands take no write lock. status still reports whether a writer holds the Repository
lock.
Parallelism
-j/--jobs N is available only where SOW parses packages, hashes bytes, renders indexes, or verifies
state: create, add, rm, build, check, and repo migrate. It defaults to the logical CPU
count and must be at least 1.
JSON output
Commands with --json emit one versioned envelope on stdout; diagnostics remain on stderr:
ok is false for every non-zero exit. A partial batch still returns its committed and failed items.
See JSON Output for complete result shapes.
Without --json, every Managed command has a stable human-readable renderer. Use it for
interactive work, not as a machine protocol. --json always selects the standard envelope and is
the only supported interface for scripts that need structured fields.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success or idempotent no-op |
1 |
Runtime I/O, parser, renderer, signing, or transport error |
2 |
Usage, Workspace discovery, or configuration error |
3 |
Partial batch success |
4 |
Write lock unavailable |
5 |
Integrity/recovery failure, or check ruling the tree not deliverable |
6 |
Expected rejection: conflict, protected object, no match, or incompatible architecture |
See Exit Codes for command-specific triggers.
5.1 - sow create
sow create turns a directory that already contains .rpm and .deb files into a flat repository
by writing indexes next to the packages. It is the whole of Plain mode: no sow.yml, no SQLite, no
Workspace discovery. This page covers the one-pass scan contract, the --pigsty completion gate,
and RPM signing with --sign-with.
Synopsis
DIR defaults to the current directory.
Description
create reads the top-level regular files in DIR and renders the index formats implied by what it
finds: repodata/ when RPMs are present, Packages and Packages.gz when DEBs are present, both
when the directory is mixed. All architectures come from the package headers — Plain mode has no
architecture flag and no permit list.
Flat metadata only ever references packages in the same directory. RPM location is the bare
basename and DEB Filename is ./<basename>, so both remain relative whether the directory is
exposed as a file:// source or an HTTP root.
By default create does not delete, move, rename, re-sign or rewrite a single package byte. It only
replaces index paths it owns; unknown files are left alone.
Options
| Flag | Description | Default |
|---|---|---|
-j, --jobs N |
Parallel workers for the single package hash/parse pass | logical CPU count |
--pigsty |
Enable Pigsty compatibility cleanup and completion marker | off |
-S, --sign-with KEY |
Sign unsigned RPMs with a 16/40/64-hex GPG key ID | off |
--overwrite |
Re-sign every RPM; requires --sign-with |
off |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
-h, --help |
Show help | — |
Scan rules
- Only top-level regular files ending in
.rpmor.debare considered. - No recursion, no symlink following, no Workspace config.
- Every valid version enters the index. Two files claiming the same logical coordinate with different content is a hard failure.
- A directory with no supported package is rejected in default mode.
--pigstyaccepts an empty authoritative set so an interrupted all-package cleanup can converge and write its marker.
Package I/O and final validation
For the normal unsigned path, each selected package has exactly one full content pass. A worker opens
it, computes SHA-256 once, parses its header/control metadata, and retains the complete parsed result.
RPM XML and DEB Packages are rendered from that retained result; neither rendering nor generated
metadata validation reopens package payloads. --jobs parallelizes this pass while canonical result
ordering keeps output bytes independent of worker scheduling.
Immediately before publication, create relists the top-level package set and compares file identity,
type/mode, size, and mtime with the post-scan snapshot. This is a cheap stat check, not a second hash.
A changed set or stat returns integrity error 5 before any staged output is published. Deliberately
preserving inode, size, and mtime while modifying bytes is outside the local cooperative-writer
contract.
Explicit RPM signing is an exception: copying, signing, signature verification, and parsing the final signed RPM necessarily add reads for packages that are modified.
Deterministic output and idempotence
The rendered metadata is byte-stable for a given input set: gzip output is deterministic, repomd.xml
carries <revision>0</revision> and timestamp 0. Running create twice on an unchanged directory
rewrites nothing and reports noop=true:
The repo_complete gate
Default mode never creates repo_complete. If the marker already exists, create refuses to write
indexes rather than leaving a stale marker claiming a build that no longer matches:
Either re-run with --pigsty (which withdraws and republishes the marker in its documented order)
or remove the marker yourself.
–pigsty
--pigsty enables three coupled compatibility actions in one invocation. Their publication order
is marker-gated, but the operation is rebuilt on retry rather than recovered from a journal:
- Delete DEB packages whose parsed architecture is
i386. RPMs are not removed merely for carrying ani386/i486/i586/i686architecture. - Delete RPM/DEB whose binary package name is exactly
patroniand whose upstream version is exactly3.0.4. RPM comparesVERSION, ignoring epoch and release; DEB strips epoch and Debian revision first.3.0.4+foois not a match. - After all indexes render successfully, write
repo_complete: the SHA-256 of every remaining top-level RPM/DEB, sorted by basename byte order, formatted<sha256><two spaces><basename>.
Cleanup only touches top-level regular package files that parsed successfully and matched a rule. Directories and unknown files are never removed by glob.
The publication order matters for callers that gate on the marker: the existing repo_complete is
withdrawn before indexes switch, matched packages are deleted only after replacement metadata is
installed, and the new marker is written last. A caller must treat a missing marker as incomplete.
Treat a missing repo_complete as “build in progress”. That is the contract --pigsty is designed
around.
Signing RPMs
-S/--sign-with KEY is the explicit authorization to modify RPM bytes. KEY is exactly 16, 40 or 64
hexadecimal characters; an 0x prefix is not accepted. SOW normalizes it to
uppercase and passes it to the environment’s rpm --addsign through the _gpg_name macro. The
private key, passphrase, GPG home, pinentry and any extra RPM macros come from your environment —
SOW never receives, persists or echoes a secret.
- Default: only RPMs with no parseable embedded OpenPGP signature are signed. Anything already signed keeps its bytes.
--overwriterequires--sign-withand switches torpm --resignover every retained RPM.- Signing happens on a private same-filesystem stage copy. Each result is re-parsed to confirm the embedded signature, the signature-neutral digest and NEVRA are unchanged, and rpm-md is generated from the final complete bytes.
- At least one top-level RPM must remain after
--pigstycleanup, andrpmmust be onPATH.
Locking, staging and overwrite rebuild
create takes a write lock on the target directory and honors --timeout/--no-wait. All metadata
is written to a private stage and validated before publication begins. The lock coordinates SOW
writers on the local machine; arbitrary external package mutation is unsupported.
Plain create does not create a durable operation journal, rollback pre-images, or recovery trash.
Publication consists of several single-file renames, so a crash may leave a partially replaced set of
derived files. Re-run sow create with the intended current options: it discards reserved stale Plain
temporary state and rebuilds all indexes from the packages that currently exist. recovered is
always false; a rerun is a fresh overwrite build, not replay.
A flat directory has no whole-repository generation pointer, and RPM plus DEB entry points cannot be
swapped in one POSIX rename. Plain therefore does not promise cross-file instantaneous atomicity. Use
repo_complete as the --pigsty gate, or use Managed mode when transactional recovery is required.
Examples
Index a mixed directory:
Machine-readable result:
Replace a Pigsty plain build with eight workers:
Failure envelope:
Exit codes
| Code | Trigger |
|---|---|
0 |
Indexes written, or unchanged input produced a no-op |
1 |
Directory unreadable or missing, package parse failure, renderer failure, signing tool failure |
2 |
Usage error — --overwrite without --sign-with, malformed key, --no-wait with a non-zero --timeout |
4 |
Directory write lock held and --no-wait given or --timeout expired |
5 |
Input set/stat changed before publication, or a controlled output path failed an integrity check |
6 |
No supported package found, repo_complete gate hit, --sign-with on a DEB-only directory, coordinate conflict |
See also
- Plain Flat Repositories — the design behind
create - Quick Start — five-minute flat repository walkthrough
- Repository Layout — what the flat tree looks like
- Sign Your Repository — generating and using a signing key
5.2 - sow init
sow init creates the root sow.yml and the private .sow/ state directory that make a directory a
Workspace. It is also the convergence command for a config you wrote by hand: if sow.yml already
declares Repositories and Dists, init materializes the ones that don’t exist yet and leaves the
finished ones alone.
Synopsis
DIR defaults to the current directory. init takes no -C/--workdir — the positional argument
already names the target unambiguously.
Description
A fresh init writes a minimal config and the private state directory:
.sow/ holds workspace.lock, the workspace-ops/ durable file journal used by Workspace lifecycle
commands, repo-locks/, and later one SQLite database per Repository. It is mode 0700 and must
never be served over HTTP.
Options
| Flag | Description | Default |
|---|---|---|
--json |
Emit the versioned JSON envelope | false |
-h, --help |
Show help | — |
Idempotence rules
init is designed to be safe to run repeatedly, in a provisioning script or by hand:
-
It writes
schema: sow/v3and the defaultarchitectures: [x86_64, aarch64]when creating a new config. -
It never creates a Repository on its own. Use
sow repo new, or declare one insow.ymlfirst. -
It never overwrites an existing
sow.yml. A repeat run reports the current state and lists what it found: -
A non-empty directory can be initialized, but the run fails if an existing file collides with a SOW reserved path.
Converging a declared configuration
If sow.yml already describes Repositories and Dists, init creates the missing directory trees,
SQLite databases and empty indexes for them. Already-initialized objects are skipped, so the counters
tell you exactly what this run did.
Every Dist created this way has a protocol-complete empty surface: an RPM Dist gets an empty
repodata/ per architecture view, and a DEB Dist gets empty Packages/Packages.gz with
by-hash plus a Release.
Running it a second time changes nothing:
Locking and recovery
Workspace lifecycle commands — init, repo new, repo rm — run before the target Repository
database exists or after it is deleted, so they use .sow/workspace.lock plus the durable file
journal in .sow/workspace-ops/ rather than a SQLite Operation Journal. An interrupted init is
completed or rolled back by the next Workspace lifecycle command.
Examples
Bootstrap a Workspace and add Repositories by hand:
Initialize a directory other than the current one:
Provision from a config file under version control:
Exit codes
| Code | Trigger |
|---|---|
0 |
Workspace created, or already converged (no-op) |
1 |
Runtime I/O error writing the config or state directory |
2 |
Usage error, or an existing sow.yml that fails to parse or validate |
3 |
Partial success — some declared Repositories/Dists were committed and at least one failed |
5 |
The Workspace journal could not be recovered to a terminal state |
6 |
An existing file collides with a SOW reserved path |
See also
- First Workspace — the ten-minute guided version
- Managed Workspaces — the three-layer model
- sow.yml Reference — every configuration key
- sow repo and sow dist
- Repository Layout — what
.sow/contains
5.3 - sow config
sow config has two read-only subcommands. config check is the full preflight over sow.yml —
run it after every hand edit and in CI. config show prints the configuration SOW actually computed,
which is where you confirm that defaults, inherited architectures and normalized aliases resolved the
way you expected.
Neither subcommand creates directories, touches a database, or corrects your file.
Synopsis
sow help config lists both.
sow config check
Parses and validates the complete sow.yml: schema version, names, path collisions, the architecture
permit list, Dist formats, membership policy and signing key references. It reports the Workspace it
resolved and how much it validated.
Options
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
--json |
Emit the versioned JSON envelope | false |
-h, --help |
Show help | — |
Strict field rejection
Unknown keys are errors, not warnings. A typo can’t silently disable a policy:
The schema version is pinned:
The only valid value is schema: sow/v3. Do not change the schema string as a
way to bypass a validation error.
check also verifies that every declared signing key reference resolves and is usable for signing —
without ever printing key material. If you remove an architecture from the permit list while a Dist
config, Membership or Built Generation still uses it, config check rejects the configuration.
sow config show
Prints the effective configuration as YAML for the currently selected scope.
Compare that with the file on disk, which carries only what you wrote:
show filled in protected: false, the inherited per-Dist architectures, limit: 0 and an empty
exclude list. Architectures are always printed as canonical families (x86_64, aarch64), never
as ecosystem aliases — amd64 and arm64 are the DEB spellings of the same two families.
Options
| Flag | Description | Default |
|---|---|---|
--all |
Expand defaults and normalized architectures across the whole Workspace | off |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-d, --dist NAME |
Select a distribution; repeatable | selection rules |
--json |
Emit the versioned JSON envelope | false |
-h, --help |
Show help | — |
Scope projection with -r and -d
-r and -d narrow the output to the selected objects. This is the fast way to answer “what policy
is actually in effect for this one Dist”:
--all goes the other way: it expands the entire Workspace regardless of where you are standing.
Secrets are never printed
Key material and passphrases never appear in config show, in JSON, in the operation log, or in
error text. Only the reference (file://…, env://…, agent://…) and fingerprint are shown.
Examples
Validate before a build in CI:
Diff effective policy across two Dists:
Exit codes
| Code | Trigger |
|---|---|
0 |
Configuration valid, or output printed |
1 |
Runtime I/O error reading the config file |
2 |
Usage error, Workspace not found, unknown field, wrong schema, or any validation failure |
6 |
A named Repository or Dist does not exist |
config check reports validation failures as exit 2, not 6: an invalid sow.yml is a
configuration error, not a rejected operation.
See also
- sow.yml Reference — every key, with a complete example file
- Membership Policy — how
excludeandlimitare evaluated - Signing Model — key reference grammar and the two trust chains
- sow init — converging a hand-written config
- sow check — the runtime counterpart that verifies bytes on disk
5.4 - sow repo
A Repository owns one pool/, one dists/, one SQLite database and one private state directory. It
is the boundary of locking, transaction recovery, Generation numbering and Changesets — nothing is
deduplicated across Repositories and no cross-Repository commit is atomic. sow repo manages that
boundary.
Synopsis
Naming
A Repository name must match [a-z0-9][a-z0-9._-]* and may not be ., .., .sow, pool,
dists, or collide with a Workspace reserved file.
You cannot choose the path. A Repository always lives at <workspace>/<NAME>/.
sow repo ls
Read-only listing of every Repository in the Workspace.
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
--json |
Emit the versioned JSON envelope | false |
STATUS is one of clean, dirty, recovering or error. See
Transactions & Recovery for what each one implies for clients.
sow repo new
Atomically updates sow.yml, then creates <workspace>/<NAME>/{pool,dists}, the SQLite database and
the private state directory. A new Repository is Generation 0 and clean.
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
repo new takes the Workspace lock, not a Repository lock — the Repository database does not exist
yet. It does not accept -r; the positional argument already names the target.
Running it again on an existing Repository is a converging no-op that reports the current state, so it is safe in a provisioning script.
sow repo show
Read-only detail for one Repository. With NAME omitted, the usual
Repository selection rules apply.
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select the repository when NAME is omitted |
selection rules |
--json |
Emit the versioned JSON envelope | false |
If you give both NAME and -r, they must agree; disagreement fails before any state is read:
sow repo migrate
This is a specialized maintenance command, not part of a fresh 0.4 Managed workflow. A Repository created by SOW 0.4 already has the current single-payload layout and schema.
After upgrading an existing v0.3 Workspace, however, migration is mandatory: stop all Workspace writers, take a backup, and run the command once for every configured Repository before ordinary reads or writes.
The 0.4 transition installs schema v11 and v12. It recomputes Repository status from every Dist, repairs publication and Generation signer projections without guessing a missing historical signer, removes stale abandoned-object evidence, and backfills revision 1 of the append-only publication-target binding ledger. An unrecorded v0.3 historical signer remains explicitly unverified; it cannot reach the current head or become a retained trust assertion.
The completed schema transition is one-way. Do not reopen the database with SOW 0.3, and do not edit
PRAGMA user_version. --abort applies only to a diagnosed pre-commit layout-maintenance attempt;
it does not undo a completed schema migration. Outside an upgrade or an explicit SOW diagnostic,
do not run migration speculatively.
| Flag | Description | Default |
|---|---|---|
-j, --jobs N |
Parallel verification/render workers | logical CPUs |
--abort |
Abandon a maintenance attempt before its commit decision | false |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select the repository when NAME is omitted |
selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
sow repo rm
Removes a Repository: its sow.yml entry, database, pool/, dists/ and private state. It never
follows symlinks and never steps outside the fixed Repository path.
Without -f, only an empty Repository — no Dists, no Memberships, no Package Objects — can be
removed:
| Flag | Description | Default |
|---|---|---|
-f, --force |
Remove a non-empty unprotected repository | false |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
What -f actually downgrades
-f only relaxes the emptiness precondition. It does not bypass path safety, symlink refusal, or
the protected gate.
protected
protected: true in sow.yml blocks Repository deletion outright, -f included:
To remove a protected Repository you must edit sow.yml, pass
sow config check, and try again. There is no --yes and no
temporary override.
protected scopes to Repository deletion only. Package-level work on a protected Repository is
unaffected — add, rm, build, and even dist rm, all continue to work:
Examples
Create the Repositories for a two-tier layout:
Fail fast in a cron job rather than queue behind another writer:
Audit every Repository in one line each:
Exit codes
| Code | Trigger |
|---|---|
0 |
Listed, created, shown, migrated, abandoned a pre-commit transition, or removed; or repo new converged an existing Repository |
1 |
Runtime I/O error creating or removing the tree |
2 |
Usage error, Workspace not found, or an ambiguous Repository selection |
4 |
Workspace lock held and --no-wait given or --timeout expired |
5 |
Integrity or recovery error in the Workspace journal |
6 |
Invalid name, unknown Repository, non-empty without -f, protected, or NAME conflicting with -r |
See also
- sow dist — the layer below
- Managed Workspaces — the three-layer model and discovery rules
- Transactions & Recovery — lock scopes and the
recoveringstate - sow.yml Reference —
protectedand per-Repository signing - Repository Layout — the fixed directory structure
5.5 - sow dist
A Dist is a named set of packages in exactly one format (rpm or deb) inside one Repository. It is
what a client points at. A Repository can hold RPM and DEB Dists side by side; they share one pool/
but render into completely separate dists/ subtrees.
Synopsis
Naming
Dist names follow the same rule as Repository names: [a-z0-9][a-z0-9._-]*, excluding ., ..,
.sow, pool and dists.
To SOW the name is an opaque string. el9, trixie, el9-beta, customer-acme, 2026-07-31 are
all just names — beta channels, per-customer views and snapshots are naming conventions you impose,
not features SOW models.
sow dist ls
Read-only flat listing of the selected Repository’s Dists.
DESIRED and BUILT are membership counts. When they diverge, DIRTY_REASONS says why:
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
--json |
Emit the versioned JSON envelope | false |
Architectures print as canonical families. The JSON output carries both spellings, which is how you
confirm that a DEB Dist renders binary-amd64 and binary-arm64:
sow dist new
Creates an ordinary, still-editable Dist. The only business argument is --format.
| Flag | Description | Default |
|---|---|---|
--format FORMAT |
Required; rpm or deb |
— |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
--format is mandatory and closed:
There is no --arch. Architectures are inherited from the Workspace permit list; an advanced user
narrows them per Dist by editing sow.yml. Policy (limit, exclude) is likewise configured in
sow.yml, never re-modelled on the command line.
Re-running dist new with the same name and the same format converges and reports the current state.
A name collision with a different format is rejected:
The three-way transaction
dist new is committed across three places at once: the sow.yml entry, the Repository database,
and the on-disk tree. It goes through the SQLite Operation Journal (the Repository database already
exists at this point, unlike repo new) and produces a new Built Generation with empty indexes.
That means a fresh Dist has a protocol-complete empty surface. An RPM Dist gets an empty
repodata/ under every architecture view; a DEB Dist gets empty Packages, Packages.gz,
the by-hash/SHA256/ entries and Release, plus InRelease/Release.gpg when signing is
configured.
sow dist show
Read-only detail for one Dist.
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
--json |
Emit the versioned JSON envelope | false |
The JSON form additionally exposes effective_config_sha256, the digest of the resolved Dist
configuration. That digest is what makes a Dist dirty when you change limit, exclude or a signing
key — the config identity changed, so the Built Generation no longer matches Desired.
sow dist rm
Removes a Dist’s Membership and derived indexes.
| Flag | Description | Default |
|---|---|---|
-f, --force |
Remove membership and indexes but retain pool packages | false |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
Dist removal does not delete Pool bytes
Removing a Dist never deletes a package from pool/. The whole Dist directory is moved into the
recovery area and removed atomically; the pool is untouched:
Orphaned pool objects remain until sow gc proves they are unreachable from every safety
root: current, retained, recovery, publication, and any active maintenance operation.
A Repository’s protected: true blocks Repository deletion only; normal Dist maintenance on a
protected Repository continues to work.
Examples
Give one Repository an RPM and a DEB face:
Add a beta channel with its own retention policy — create it, then set the policy in sow.yml and
converge:
Which Dists are behind their Desired state:
Exit codes
| Code | Trigger |
|---|---|
0 |
Listed, created, shown or removed; or dist new converged an existing Dist |
1 |
Runtime I/O or renderer error creating the empty indexes |
2 |
Usage error — missing or invalid --format, Workspace not found, ambiguous Repository |
4 |
Repository lock held and --no-wait given or --timeout expired |
5 |
Integrity or recovery error in the Operation Journal |
6 |
Invalid name, unknown Dist, format conflict with an existing name, non-empty without -f |
See also
- sow repo — the layer above
- Pool & Architecture Views — how one Dist becomes several client-visible views
- Membership Policy — configuring
limitandexclude - sow build — converging after a policy edit
- Repository Layout — the RPM and DEB
dists/structures
5.6 - sow add
sow add is the main write path. It parses the packages you point at, derives their format and
architecture from the package headers, applies the Dist’s membership policy, and — unless you pass
--skip — rebuilds every affected index before it returns. When the command exits 0, clients can
already see the new packages.
Synopsis
Options
| Flag | Description | Default |
|---|---|---|
-R, --recursive |
Descend into subdirectories of a PATH directory |
off (top level only) |
--skip |
Update Desired state only; do not build | off |
-j, --jobs N |
Parallel workers for parsing, hashing and rendering | logical CPU count |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-d, --dist NAME |
Select a distribution; repeatable | selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
Inputs and targets
PATH can be a file or a directory. A directory is scanned top level only unless you pass -R.
You must end up with exactly one Repository and at least one target Dist — see the selection rules. Mixed RPM/DEB batches are fine: each package is only considered for target Dists of its own format. A package with no compatible target fails.
SOW never infers the target from a manifest, a directory name, or the host OS.
The summary line reports the Operation ID, per-item counts, the membership delta, the new Desired
Revision, the Built Generation and whether the Repository is left dirty. Then one item line per
input, in stable order.
Item statuses
Each item line carries an overall status plus a per-Dist verdict in dists=.
| Status | Meaning |
|---|---|
accepted |
New Package Object created and at least one Membership added |
reused |
The content already exists in this Repository; only Membership references may change |
excluded |
Policy removed it from every target Dist — see the dists= field for excluded vs limited |
failed |
The package was rejected; the error= field says why |
reused is content idempotence: adding the same file twice never creates a second object or a
duplicate Membership. A default repeated add also converges the selected Dist. If it is already
current, the Generation stays put; if an earlier --skip or configuration change left it dirty,
the reused input triggers the missing build and may advance the Generation:
The same object added to a second Dist is also reused — the pool keeps one copy and gains a second
Membership. Use --skip again when the intent is to retain a dirty batch instead of converging it.
Architecture is read, never guessed
add reads format and native architecture from the package header, then checks the Workspace permit
list. An architecture outside the list fails the package and tells you exactly what to edit:
It does not create a directory and does not modify sow.yml.
RPM noarch and DEB all are architecture-neutral. They create one Package Object and one
Membership, and render into every effective architecture view of the target Dist. They do not
spread to Dists you did not select with -d.
Policy: exclude and limit
After merging into the target Memberships, SOW re-evaluates exclude and then limit over the
complete Dist candidate set. A package removed by policy is reported, not treated as a parse failure.
Here trixielim has exclude: [{kind: [dbgsym]}] and limit: 1. The dbgsym package was excluded by
rule; libpq5 18.2-1 lost to 18.3-1 under the version limit and is reported as limited. Both
appear as excluded in the top-level status, and the dists= field distinguishes them.
Limit groups by (binary name, native architecture), so 18.3-1:amd64 and 18.3-1:arm64 both
survive a limit: 1. A package can be accepted by one Dist and skipped by another in the same run.
exclude and limit remove real Desired Memberships. Relaxing the policy later does not resurrect
them — leftover bytes in pool/ are not a candidate set. Re-run sow add.
Partial batches
Valid, conflict-free packages are committed even when siblings fail. Failed inputs stay where they
are, each with its own error, and the command exits 3:
If nothing is accepted, the whole operation is rejected with exit 6 and the Repository is
unchanged:
There is no rejected/quarantine directory.
–skip
--skip stops after the Desired state is committed. The public pool/ and dists/ bytes do not
change, the Built Generation stays where it was, and the Repository becomes dirty. New package bytes
are durably held in a private pending store until the next build publishes them.
pending=4/2326 is four objects totalling 2326 bytes waiting in the private store. They never appear
in sow changes — only a successful build promotes them into the
deliverable tree.
Use --skip for bulk imports, then converge once:
Processing order
For the record, one add executes in this order:
- Take the Repository write lock and recover any unfinished Operation.
- Commit a
plannedOperation in SQLite. - Parse inputs read-only; compute logical coordinates and input SHA-256 (plus, for RPM, the signature-neutral payload digest).
- Check the architecture permit list and look up existing coordinates.
- For genuinely new coordinates only, run optional RPM signing on a stage copy and compute the final SHA-256, then verify content and path uniqueness.
- Merge target Memberships, then apply
excludeandlimitover the complete Dist set. - Commit the Desired state; new bytes go to the private pending content store.
- Unless
--skip, publish still-needed pending objects intopool/and render indexes — each Dist is built at most once per command.
Input files are never modified, moved or deleted, in any mode.
RPM signing modes
Managed RPM package signing is configured in sow.yml under signing.rpm.packages.mode; there is no
command-line override.
| Mode | Behavior |
|---|---|
never |
Keep the input bytes exactly |
fill |
Sign when unsigned or when the signature is not trusted; keep bytes when a trusted_keys signature verifies. Default when a key is configured |
always |
Ensure the final package is validly signed by the configured key; re-sign a stage copy otherwise |
Without a configured key only never is available.
Because a signature embeds non-deterministic fields, SOW cannot re-sign and then compare final
hashes. Retry idempotence works on the coordinate instead: identical input bytes are reused
directly; an identical RPM signature-neutral digest is reused when the existing object satisfies the
current policy. A different payload digest, or an existing object that no longer satisfies the
policy, is a hard conflict — add will not silently re-sign a coordinate in place.
Exit codes
| Code | Trigger |
|---|---|
0 |
Every input accepted or reused; indexes rebuilt (or skipped with --skip) |
1 |
Runtime I/O, parser, renderer or signing failure |
2 |
Usage error, Workspace not found, or ambiguous Repository/Dist selection |
3 |
Partial batch — at least one item committed and at least one failed |
4 |
Repository lock held and --no-wait given or --timeout expired |
5 |
Integrity or recovery error, including a build that failed after applied |
6 |
Nothing accepted — unsupported architecture, no compatible target Dist, or a coordinate conflict |
See also
- sow rm — the inverse operation
- sow build — converging after
--skip - Membership Policy —
excluderules andlimitsemantics in full - Signing Model — the two trust chains and key references
- Package References — the coordinate grammar printed on each item line
5.7 - sow rm
sow rm takes packages out of the Desired Membership of the Dists you select and, by default,
rebuilds the affected indexes immediately. It does not delete bytes from pool/ — membership and
content are separate concepts, and reclamation is the separate conservative sow gc operation.
Synopsis
Options
| Flag | Description | Default |
|---|---|---|
-c, --check |
Preview only; compute and print the plan without writing anything | off |
--skip |
Update Desired state only; do not build | off |
-j, --jobs N |
Parallel workers | logical CPU count |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-d, --dist NAME |
Select a distribution; repeatable | selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
--check and --skip are mutually exclusive:
Package references
PACKAGE accepts five forms. The full grammar and disambiguation rules are on
Package References; the short version:
| Form | Example |
|---|---|
| Content hash | sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab |
| RPM coordinate | rpm:epel-release-0:7-5.noarch |
| DEB coordinate | deb:libpq5=18.3-1:amd64 |
| Full filename | epel-release-7-5.noarch.rpm |
| Bare binary name | epel-release |
A bare name means every version and native architecture of that name in the selected Dists — that
is what makes sow rm patroni a useful takedown command. An ambiguous short reference that is not a
bare name fails and lists the candidates instead of guessing.
sow ls prints exact sha256: references and canonical coordinates,
so you never have to assemble one by hand.
A reference matching nothing is a rejection, not a silent success:
There is no --allow-empty, no --all, no --yes and no --source-list.
Preview with –check
-c/--check computes exactly what would be removed, what policy would then decide, and which files
an immediate build would touch — and writes nothing at all.
Note both centos-release versions matched the bare name. The change lines are a real delivery
plan in payload → metadata → pointer → delete phase order. Use --json when another program needs
the corresponding removed[] and changes[] arrays.
Preview uses the same candidate-configuration and integrity preflight as the mutation. A preview that fails that guard is not evidence that the write would succeed.
--check deliberately does not take the write lock. Combining it with lock flags is a usage error,
so nobody can believe a preview is queueing behind a writer:
Default behavior: remove and rebuild
Without --check or --skip, rm commits the Desired change and rebuilds every affected Dist
before returning. Pool objects stay on disk.
The summary is followed by one change line per affected file (eight in this run). Add --json to
receive the same result as a stable standard envelope.
Removing the last member of a Dist is fine. SOW still renders a valid, signed-if-configured empty
index — an empty Packages with a verifiable InRelease, or empty per-architecture repodata/.
–skip
--skip commits the Desired change and marks the Repository dirty without touching the public tree.
The old Built Generation stays completely self-consistent for clients.
changes is empty because nothing was built. Run sow build to
converge.
Policy interaction
Removals are Desired-state edits, so policy is re-evaluated over the resulting candidate set — a
removal will never resurrect a package that limit previously pushed out. If you remove libpq5 18.3-1 from a limit: 1 Dist, 18.2-1 does not come back; add it again explicitly.
Examples
Safe takedown — preview first, then execute:
Remove one exact object from two Dists at once:
Batch several removals, then rebuild once:
Feed the preview plan to another tool:
Exit codes
| Code | Trigger |
|---|---|
0 |
Memberships removed and rebuilt, or a --check preview printed |
1 |
Runtime I/O or renderer failure |
2 |
Usage error — --check with --skip, --check with lock flags, ambiguous selection, Workspace not found |
3 |
Partial batch — at least one reference removed and at least one failed |
4 |
Repository lock held and --no-wait given or --timeout expired |
5 |
Integrity or recovery error |
6 |
A reference matched nothing, or an ambiguous non-bare reference |
See also
- Package References — the full reference grammar
sow ls,show, andwhere— finding the exact reference to remove- sow add — the inverse operation
- sow build — converging after
--skip - Membership Policy — why removed members do not come back
sow retainandsow gc— when bytes can be reclaimed
5.8 - sow ls
sow ls is a read-only query over Package Objects and Dist Membership. It shows what each selected
Dist should contain and whether that membership is present in the current Built Generation.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
-d, --dist NAME |
Select a Dist; repeatable | selection rules |
--json |
Emit the sow.cli/v1 envelope |
false |
There is no --pool, --match, or output-format flag.
Output
| Column | Meaning |
|---|---|
SHA256 |
Immutable content identity; valid input to show and rm |
COORDINATE |
Canonical rpm: or deb: package reference |
DISTS |
Desired Membership across the selected scope |
BUILT_DISTS |
Membership present in the current Built Generation |
POOL_PATH |
Repository-relative immutable payload path |
The first line reports dirty=true when Desired and Built state differ. An empty BUILT_DISTS
field means the package is desired but clients cannot see it yet. Run sow build
to converge.
An object shared by several selected Dists appears once, with comma-separated membership values. An empty Dist has a header and no package rows; that is a successful result.
Selection
ls requires an unambiguous Dist set. In a multi-Dist Repository, pass one or more -d values or
run from inside <repo>/dists/<dist>/.
The command takes no write lock and does not hash package files. --json returns the same rows in
result.packages.
Examples
List exact references for all objects not yet built:
List pool paths in deterministic order:
Exit codes
| Code | Trigger |
|---|---|
0 |
Membership printed, including an empty list |
1 |
Runtime I/O failure |
2 |
Usage error, Workspace not found, or implicit Repository/Dist selection is ambiguous |
5 |
Repository state database unreadable or inconsistent |
6 |
Explicit Repository or Dist is not configured |
See also
sow show— inspect one listed objectsow where— locate an object across the Workspacesow rm— remove a listed reference from Desired Membership- Package References — accepted identity forms
5.9 - sow show
sow show resolves one package reference in the selected Repository and prints the complete Package
Object. It is read-only and takes no write lock.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
-d, --dist NAME |
Narrow candidates to these Dists; repeatable | Repository scope |
--json |
Wrap the result in the sow.cli/v1 envelope |
false |
Package reference
PACKAGE accepts a sha256:<hex> content identity, canonical rpm:<NEVRA> or
deb:<name>=<version>:<arch> coordinate, full package filename, or bare binary name. See
Package References for the exact grammar.
A bare name must resolve to exactly one Package Object in the selected scope. Unlike sow rm foo,
which removes every matching version from Desired Membership, sow show foo refuses ambiguity and
prints the candidates:
Copy an exact coordinate or SHA-256 from the error or from sow ls and retry.
Output
Without --json, show prints the identity, storage path, and Desired/Built locations in a compact
human-readable form:
Adding --json returns the complete Package Object under result in the standard envelope,
including the normalized fields below.
| Field | Meaning |
|---|---|
canonical_arch |
x86_64, aarch64, or neutral for RPM noarch / DEB all |
kind |
Policy class: main, debuginfo, debugsource, llvmjit, dbgsym, or dbg |
source |
Normalized source-package name |
payload_sha256 |
RPM signature-neutral digest used for re-signing idempotence |
signature_key |
Embedded package-signature key ID, when present |
storage |
pending before build; pool once published into the repository tree |
dists / built_dists |
Desired and current Built Membership |
-d narrows candidate resolution; it does not alter package identity.
Exit codes
| Code | Trigger |
|---|---|
0 |
One Package Object printed |
1 |
Runtime I/O failure |
2 |
Usage error, Workspace not found, or implicit Repository selection is ambiguous |
5 |
Repository state database unreadable or inconsistent |
6 |
Explicit scope is not configured, or the reference matched nothing/several objects |
See also
sow ls— obtain exact identities from Dist Membershipsow where— search across Repositoriessow rm— remove matching Desired Membership- JSON Output — complete result schema
5.10 - sow where
sow where answers which Dists in the Workspace still carry one Package Object. It is read-only,
searches every Repository by default, and takes no write lock.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Restrict the search to one Repository | all Repositories |
-d, --dist NAME |
Restrict the search to named Dists; repeatable | all Dists |
--json |
Emit the sow.cli/v1 envelope |
false |
Reference resolution
PACKAGE uses the same grammar as sow show: SHA-256 identity, canonical
RPM/DEB coordinate, full filename, or bare name.
Resolution happens across the complete selected scope. A bare name must identify one Package Object;
different objects with the same binary name are ambiguous even when they live in different
Repositories. Use -r/-d, or supply an exact coordinate or SHA-256.
Output
Without --json, where prints a summary followed by one line per location:
Each location reports both Desired dists and current built_dists. This makes the command useful
for answering whether a removed or superseded build is still client-visible anywhere.
With --json, the same object appears under result. A missing reference is an expected rejection,
not an empty success:
Example
List every location still serving an exact build:
Exit codes
| Code | Trigger |
|---|---|
0 |
One resolved Package Object and its locations printed |
1 |
Runtime I/O failure |
2 |
Usage error or Workspace not found |
5 |
A Repository state database is unreadable or inconsistent |
6 |
Explicit Repository/Dist is not configured, or the reference matched nothing/was ambiguous |
See also
sow show— inspect the resolved Package Objectsow ls— list one Dist set- Package References — exact grammar and ambiguity rules
5.11 - sow status
sow status is the cheap Repository health query. It reads state but does not hash files, verify
signatures, recover Operations, build metadata, or take the write lock.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
-d, --dist NAME |
Restrict status to these Dists; repeatable | all Dists |
--json |
Emit the sow.cli/v1 envelope |
false |
Repository states
Every Repository tracks a Desired Revision in SQLite and the Built Generation represented by its
public dists/ tree.
| State | Meaning | Public view |
|---|---|---|
clean |
Desired state matches Built state | current, complete Generation |
dirty |
Desired state is ahead, commonly after --skip or a config change |
previous complete Generation |
recovering |
A non-terminal Operation must be recovered by the next write command | last completed protocol pointer |
error |
Automatic recovery cannot choose safely | last completed view; no overwrite attempted |
Dirty never means a half-written repository. Readers see either the old complete view or the new complete view because protocol pointers are switched last.
Output
The human line reports:
- Repository state and
ready_to_copy; - Desired Revision and current Built Generation;
- affected Dists;
- pending object count and bytes;
- write-lock state.
The JSON result additionally includes dirty_reasons and the most recent Operation:
ready_to_copy=false is a hard warning. true is only a cheap state result, not a byte-level proof;
run sow check before delivery.
Read-only contract
status never migrates or repairs state. If the Repository database cannot be read safely,
the command exits 5; run the maintenance command named by the diagnostic before retrying. In
particular, back up and run sow repo migrate for every
v0.3 Repository before using the 0.4 read surface.
Exit behavior
status returns 0 for every readable state, including dirty, recovering, and error. Scripts
should inspect the structured state rather than treating those conditions as command failures.
| Code | Trigger |
|---|---|
0 |
Repository state is readable |
1 |
Runtime I/O failure |
2 |
Usage error, Workspace not found, or implicit Repository selection is ambiguous |
5 |
State database unreadable or inconsistent |
6 |
Explicit Repository or Dist is not configured |
See also
sow build— converge a dirty Repositorysow check— use a full integrity/readiness gatesow log— inspect the recent Operation reported here- Transactions & Recovery — state transitions and pointer safety
5.12 - sow build
sow build is the explicit Desired-to-Built convergence command. It acquires the Repository write
lock, recovers any decidable unfinished Operation, renders and verifies a complete Generation, then
switches protocol pointers last.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-j, --jobs N |
Parallel workers; must be at least 1 |
logical CPU count |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
-d, --dist NAME |
Build named Dists; repeatable | all affected Dists |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Wrap the result in the sow.cli/v1 envelope |
false |
Without -d, SOW converges every affected Dist in the selected Repository. With -d, only those
Dists converge; unselected changes remain dirty.
Result
Without --json, build prints one human-readable summary:
Use --json for the command-specific object inside the standard envelope.
No-op builds
When membership, relevant policy, renderer settings, and signing configuration already match the
Built Generation, build is an idempotent no-op and does not increment the Generation:
Policy convergence
build re-evaluates the current exclude and limit policy. Tightening policy may remove Desired
Membership. Loosening policy does not reconstruct historical members from leftover pool bytes; run
sow add again for packages you want to restore.
Commit and recovery
SOW stages new metadata on the same filesystem, verifies it, then switches mutable protocol pointers last. RPM checksum-named metadata and APT by-hash keep old and new readers self-consistent.
Pending package promotion is a bounded, single-writer group commit. Each batch contains at most 512 objects or 1 GiB: SOW creates Pool links, persists every distinct target parent, then removes pending names and persists the shared pending directory. An interruption can leave a pending-only, exact dual-link, or Pool-only state, all recoverable from the journal; it cannot durably lose both names.
One Operation may cover several Dists. Each Dist always exposes a complete view; when build
returns, every included Dist belongs to the same Built Generation.
Before starting new work, build attempts forward recovery or safe rollback of a non-terminal
Operation. If journal, database, and filesystem evidence contradict each other, the Repository enters
error and build refuses to guess. There is no force-repair flag.
Progress events
Long builds append structured build_progress records to the Operation log. Each event
contains phase, completed, total, and jobs. Current phases are:
rendering;promoting_payload;publishing_dists;normalizing_public_tree;finalizing.
These events do not advance the Operation state and deliberately do not checkpoint SQLite
after every update. They are audit/observability records, not recovery decisions. Inspect
them with sow log OPERATION.
Metadata signing
Managed metadata signing is configured only in sow.yml; there is no command-line key override.
Changing a configured key reference or fingerprint makes affected Dists dirty, and the next build
re-signs their metadata.
- RPM: always writes
repodata/repomd.xml; writesrepomd.xml.ascwhen configured. - DEB: always writes
Release; writesInReleaseandRelease.gpgwhen configured.
Exit codes
| Code | Trigger |
|---|---|
0 |
Converged successfully or nothing to do |
1 |
Renderer, signing, or filesystem failure |
2 |
Usage error, Workspace not found, or implicit Repository selection is ambiguous |
4 |
Repository write lock unavailable |
5 |
Recovery cannot complete safely, or Repository is in error |
6 |
Explicit scope is not configured, or current configuration rejects existing state |
See also
sow status— determine whether convergence is neededsow check— verify the resulting treesow changes— inspect the resulting file delta- Transactions & Recovery — full commit protocol
5.13 - sow check
sow check is the deep read-only gate for a managed Repository. It hashes bytes, validates state,
reconstructs expected views, and verifies declared signatures. It never repairs, builds, recovers an
Operation, or takes the write lock.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-j, --jobs N |
Parallel verification workers; at least 1 |
logical CPU count |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
-d, --dist NAME |
Verify named Dists; repeatable | all Dists |
--json |
Emit the sow.cli/v1 envelope |
false |
Verification layers
In steady state, the checker reports nine ordered layers:
| Layer | Verification | checked counts |
|---|---|---|
config |
sow.yml parses and validates for the Repository |
configuration objects |
state |
SQLite quick_check, foreign keys, journal, and recovery evidence |
one state database |
public-modes |
File and directory permissions across the served tree | inspected paths |
retained |
Explicit retained records and frozen Generation manifests | retained records |
package-bytes |
SHA-256 of pool and private pending payloads | Package Objects |
desired-membership |
Membership rows resolve under current policy | memberships |
index |
Rendered indexes match the membership they claim | Dists |
signature |
Every declared metadata and package signature verifies | signatures |
generation-manifest |
Built Generation manifest matches files on disk | one manifest |
During a non-terminal Repository layout transition, check instead reports config, state,
public-modes, then a conditional layout-transition layer and stops. It returns not-ready until
the diagnosed repo migrate operation completes or is
aborted before commit.
Physical evidence and I/O contract
package-bytes never trusts a cached fingerprint as a substitute for authenticity. Each run hashes
every unique physical payload exactly once, using descriptor-bound evidence keyed by device, inode,
size, mtime, and ctime. Hard links to the same inode share that proof; retained Generations, final
manifest traversal, and changes reuse it without another payload scan. The checked column counts
logical objects, not the number of full-file streams.
A DEB or unsigned RPM needs one complete payload stream. A signed RPM uses at most one additional main-header-to-EOF stream to verify every signature packet against all candidate trust rings; the cost does not grow with Dist, retained Generation, or trust-ring count. A forged or concurrently replaced file invalidates the descriptor evidence and fails closed.
Dirty is not deliverable
A dirty Repository can have nine individually valid layers: the old Built Generation is intact and the new Desired state is valid. It still fails the delivery gate because the two do not match:
The exit code is 5. Run sow build and check again. Do not weaken a release
pipeline to accept this state.
Exit codes
| Code | Trigger |
|---|---|
0 |
Every layer passes and the Repository is ready to copy |
1 |
I/O failure during verification |
2 |
Usage error, Workspace not found, or implicit Repository selection is ambiguous |
5 |
A verification layer failed, or Repository is not deliverable |
6 |
Explicit Repository or Dist is not configured |
See also
sow status— cheap state querysow build— converge Desired and Built state- Exit Codes — why a dirty Repository maps to
5 - Observability & Audit — operating checks and audit together
5.14 - sow changes
sow changes compares Built Generations. It reports physical Repository-relative file changes; it
does not show unbuilt Desired changes and is not a remote transaction protocol.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
--json |
Emit the sow.cli/v1 envelope |
false |
The command is Repository-wide and rejects -d/--dist.
Output
Columns are operation, phase, Repository-relative path, size, and SHA-256.
| Field | Values |
|---|---|
| operation | add, update, delete |
| phase | payload, metadata, pointer, delete |
Phases describe how SOW constructed the local Generation. Do not replay individual rows into a live
remote tree. Use sow publish, or stage and atomically switch a complete
copy.
Base Generation
Without an argument, SOW compares the current Built Generation with its predecessor.
BASE_GENERATION is a decimal integer in the inclusive range 0..current. Base 0 produces the
complete delivery manifest for the current Generation, excluding private sow.yml and .sow/.
Using the current Generation as base produces an empty plan. A Repository never built also yields an
empty 0 -> 0 plan.
Dirty and recovery states
When Desired state is dirty, the header says dirty=true, but the plan still ends at the current
Built Generation. Private pending payloads are excluded because they are not deliverable yet.
When the Repository is recovering or error, changes refuses to emit a plan: pending file
actions must not be mistaken for a completed Generation.
Examples
Produce a complete manifest:
Filter one Dist by path after producing the Repository-level plan:
Exit codes
| Code | Trigger |
|---|---|
0 |
Plan printed, including an empty plan |
1 |
Runtime I/O failure |
2 |
Usage error, -d supplied, Workspace not found, or implicit Repository selection is ambiguous |
5 |
Repository is recovering or error, or state evidence is inconsistent |
6 |
Explicit Repository is not configured, or Base Generation is outside the valid range |
See also
sow build— create the next Generationsow publish— apply the supported publication protocolsow log— semantic Operations and their file actions- Repository Layout — public and private path boundaries
5.15 - sow publish
sow publish delivers one Repository’s current Built Generation to a named target from the
targets: map in sow.yml. The target binds its Repository and provider; the command does not accept
--repo or --dist.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
--abort |
Abandon a reconciled attempt that has not reached durable commit intent | false |
--rebind |
Confirm and record permitted target name/public endpoint/cache-TTL changes | false |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-T, --timeout DUR |
Maximum Repository-lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the sow.cli/v1 envelope |
false |
TARGET must name a configured filesystem or r2 publication target. --abort and
--rebind are mutually exclusive.
Publication protocol
Before delivery, SOW requires a completed Built Generation and verifies that the public tree is the exact frozen Generation manifest. It then plans and applies objects in this order:
- immutable payloads;
- checksum-addressed metadata;
- mutable protocol pointers;
- verification and durable checkpoint.
The exact object set, receipts, phase, and commit intent are recorded so an interrupted publication can be reconciled. Repeating a publication already at the current Generation is an idempotent no-op.
Rebind mutable target settings
The first successful publish durably binds a Repository, storage namespace, and target identity.
Later configuration drift is rejected rather than silently adopted. When the diagnostic explicitly
points to --rebind, review the edit and confirm it with:
If the map key itself was renamed, invoke the new target name. Rebind preserves active attempt and checkpoint identities and appends an immutable operator-confirmed binding revision.
| May change | Immutable; configure a new target |
|---|---|
| target name | Repository identity |
public_endpoint |
provider, storage endpoint, or region |
max_cache_ttl |
bucket or prefix |
Rebind takes the same Workspace and Repository locks as publication and rechecks the immutable
fields inside the database transaction. It may resume an active commit-intent attempt forward.
It refuses a TTL change while target maintenance is pending, and refuses a filesystem
public_endpoint change during conditional-delete maintenance. A first bind must use ordinary
publish, not --rebind.
Abort and recovery
--abort is valid only before durable commit intent. SOW reconciles objects already created, keeps
the evidence required for later safety decisions, and abandons the attempt without copying or
deleting more remote objects.
After commit intent, recovery is forward-only. Rerun sow publish TARGET; do not use --abort.
Public visibility checks
Provider storage success is not enough: publication verifies the canonical public_endpoint before
recording its checkpoint. HTTP(S) targets use ordinary GET as the final authority. A no-cache probe
may accelerate revalidation, but only a later ordinary GET can pass. Stale content and missing
objects are retried up to max_cache_ttl; 408, 425, 429, and 5xx failures get a short bounded retry
window. Header wait and body idle progress have separate deadlines, and an oversized body fails
closed.
Filesystem targets may use file:// or HTTP(S). Their conditional-deletion absence check requires
the exact file identity for file://, or canonical 404/410 visibility for HTTP(S). R2 requires an
HTTP(S) public endpoint; R2 target GC remains report-only and does not perform remote deletion.
Safety boundaries
- SOW publishes only configured targets; there is no arbitrary destination argument.
- Unbuilt Desired changes are never included. A dirty Repository can therefore publish its previous
complete Built Generation; run
buildfirst when the target must reflect current Desired state. - Layout transitions and contradictory recovery evidence block publication. Decidable unfinished Dist work is recovered before the source Generation is selected.
- Object order protects package-manager pointers from referencing absent content.
- Publication does not make an external web server, bucket policy, DNS route, or cache correct; those remain deployment concerns.
Exit behavior
| Code | Trigger |
|---|---|
0 |
Publication completed or target was already current |
1 |
Filesystem, provider, network, verification, or binding conflict (including required rebind) |
2 |
Usage, Workspace discovery, or invalid sow.yml error |
4 |
Repository write lock unavailable |
5 |
Local or publication recovery evidence is inconsistent, or source is not deliverable |
6 |
Target is missing/unsafe, or another safety precondition rejects publish/abort/rebind |
See also
sow statusandsow check— decide whether the Built Generation is the one you intend to deliversow gc— conservative target maintenancesow.ymltargets — provider configuration- Publication Model — phases, receipts, recovery, and cache grace
5.16 - sow retain
sow retain manages explicit local Generation roots. retain add can freeze only the current Built
Generation; after later builds make it historical, its required package payloads remain protected.
Synopsis
GENERATION must be a decimal integer greater than zero.
retain add
Requires GENERATION to equal the current Built Generation, verifies it, freezes its manifest under
private Workspace state, and adds an explicit GC root. Older Generations cannot be recreated after
the fact with retain add.
The retained record protects payloads; it does not switch the current view or publish anything. Adding an already retained Generation is idempotent only when the verified record agrees with current evidence.
retain ls
Lists explicit retained records. It is read-only and therefore accepts neither lock options nor
--dist.
An empty list is a successful result.
retain rm
Removes only the explicit retained root:
It does not delete package bytes. Removing a Generation that is not retained is an idempotent no-op.
A later local sow gc may reclaim payloads only if no other safety root reaches
them.
Options
| Flag | Commands | Meaning |
|---|---|---|
-C, --workdir DIR |
all | Workspace discovery start directory |
-r, --repo NAME |
all | Select a Repository |
-T, --timeout DUR |
add, rm |
Maximum write-lock wait |
-N, --no-wait |
add, rm |
Fail immediately when locked |
--json |
all | Emit the sow.cli/v1 envelope |
Exit behavior
| Code | Trigger |
|---|---|
0 |
Requested operation completed, including an empty list |
1 |
Filesystem or runtime I/O failure |
2 |
Invalid Generation syntax, discovery error, or implicit Repository selection is ambiguous |
4 |
Write lock unavailable for add or rm |
5 |
Generation manifest or Repository state is inconsistent |
6 |
Explicit Repository is not configured, retain add does not name the current Built Generation, or another safety rule rejects the request |
See also
sow gc— consumes retention rootssow changes— inspect a Built Generation delta- Repository Layout — private retained-record location
5.17 - sow gc
sow gc has two deliberately separate modes. With no positional target it collects unreachable
local pool payloads. With TARGET it maintains one configured publication target.
Synopsis
| Flag | Meaning | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository for local GC only | selection rules |
-T, --timeout DUR |
Maximum Repository-lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the sow.cli/v1 envelope |
false |
gc TARGET -r NAME is a usage error because the target already binds a Repository. --dist is not
accepted in either mode.
Local GC
Local GC deletes only pool payloads unreachable from every safety root:
- the current Built Generation;
- explicit
retainrecords; - recovery and non-terminal Operation state;
- publication attempts and evidence;
- active maintenance work.
The operation is journaled. When payloads are removed, the Repository advances to a new Generation. When nothing is eligible, it is an idempotent no-op.
Target GC
Target maintenance is provider-specific and uses publication checkpoints, absence evidence, and configured cache grace:
| Provider | Behavior |
|---|---|
filesystem |
Conditionally delete eligible objects only after grace and recorded storage/public absence checks |
r2 |
Persist an exact report-only retained-candidate set; never issue object deletion |
A no-op means no maintenance is due, not that the target was exhaustively revalidated.
Exit behavior
| Code | Trigger |
|---|---|
0 |
GC completed or nothing was eligible |
1 |
Filesystem, provider, network, or runtime failure |
2 |
Usage, Workspace discovery, invalid sow.yml, or implicit Repository ambiguity |
4 |
Repository write lock unavailable |
5 |
Recovery, state, receipt, or manifest evidence is inconsistent |
6 |
Explicit Repository/target is not configured or safe, or deletion is rejected by a safety precondition |
See also
sow retain— create and remove explicit local rootssow publish— create target checkpoints and receipts- Publication Model — provider guarantees and cache grace
5.18 - sow export
SOW provides one export subcommand: sow export rpm-leaf. It creates an external, standalone
RPM repository whose repodata uses local pool/... hrefs.
Synopsis
| Argument | Requirement |
|---|---|
DIST |
Canonical configured RPM Dist name |
ARCH |
x86_64 or aarch64 |
DIR |
Absent or empty destination outside Repository, private-state, and filesystem-target roots |
| Flag | Meaning | Default |
|---|---|---|
--hardlink |
Use hard links for trusted, same-filesystem, read-only output | copy files |
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a Repository | selection rules |
--json |
Emit the sow.cli/v1 envelope |
false |
The command accepts no --dist, jobs, timeout, or lock option.
Output
The destination contains:
- rewritten RPM repodata with local payload hrefs;
- the required package subtree;
- an export manifest;
.sow-export.jsonprovenance.
The source must be a completed Built Generation. The export is a separate artifact: it is not Desired Membership, a Built Generation, publication input, or a GC root.
Copy versus hard link
Copying is the safe default. --hardlink is an explicit optimization for an output on the same
filesystem that consumers cannot mutate. A hard-linked payload shares an inode with SOW’s pool; do
not use this mode for writable or untrusted destinations.
SOW rejects output that overlaps a configured filesystem publication root. This prevents an export from being mistaken for, or modifying, a managed publication target.
Exit behavior
| Code | Trigger |
|---|---|
0 |
Standalone RPM leaf exported |
1 |
Filesystem, copy, hard-link, or metadata-write failure |
2 |
Invalid syntax, malformed Dist/architecture token, discovery, or implicit Repository ambiguity |
5 |
Source Generation or Repository state is inconsistent |
6 |
Explicit Repository is not configured, Dist is not RPM, view/signer is unavailable, or destination is unsafe/non-empty/overlapping |
See also
- Platforms & Integrations — validated and unsupported workflows
- Repository Layout — source tree and export boundaries
sow publish— managed delivery to configured targets
5.19 - sow log
Every write command inside a Repository commits an application-level Operation to that Repository’s
SQLite database before it produces any external file side effect. That record is what makes crash
recovery possible — and once the Operation reaches a terminal state, the same record is your audit
trail. sow log reads it.
Synopsis
Operation lifecycle
Understanding the state field is most of understanding the log:
| State | Meaning |
|---|---|
planned |
Command, arguments, targets and intended actions are durably recorded |
staged |
New packages/metadata written to temporary locations and verified |
applied |
Desired state and required private pending payloads committed |
built |
The complete static Generation has been switched in |
done |
Terminal — a normal successful command |
done_dirty |
Terminal — --skip was given, so the public tree deliberately stayed behind |
failed |
Terminal — the operation failed before applied, nothing was committed |
rolled_back |
Terminal — a post-applied failure the process could safely undo |
recovering |
Non-terminal; the next write command must complete or roll it back |
Workspace lifecycle commands (init, repo new, repo rm) use the Workspace file journal instead
and do not appear in a Repository’s SQLite log. dist new/dist rm do appear — the Repository
database already exists at that point.
sow log
With no argument, prints the 50 most recent Operations, newest first.
Output excerpt, one Operation object from the operations array:
payload_json records the intent — including config_sha256, the digest of the configuration in
force, and manifest_sha256 for the resulting Generation. result_json records the outcome. A
failed Operation additionally carries error_class and error_message:
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-d, --dist NAME |
Show only Operations touching this Dist | all |
--json |
Emit the versioned JSON envelope | false |
One Operation in detail
Pass an Operation ID to get its full state transitions, timing, packages, memberships and file actions.
Output excerpt:
The files array uses the same phase vocabulary as
sow changes: payload, metadata, pointer, delete.
Build Operations also contain progress events. They keep the current state and put a
versioned object in detail_json:
The phases are rendering, promoting_payload, publishing_dists,
normalizing_public_tree, and finalizing. A progress row is durable audit data but does
not advance the recovery state machine or force its own SQLite checkpoint.
Filtering by Dist
-d restricts the listing to Operations that touched that Dist — useful when one Repository serves
several distributions:
sow log export
Writes terminal Operations as JSONL — one complete Operation detail record per line — for archival or ingestion into a log pipeline.
Omit FILE, or pass -, to write to stdout:
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-d, --dist NAME |
Export only Operations touching this Dist | all |
export has no --json; JSONL is its output format.
It refuses to clobber
An existing target is a rejection, never an overwrite — an audit export must not silently destroy a previous one:
export also refuses a target whose parent is not a real directory — a symlink, or a directory that
does not exist:
On macOS /tmp is a symlink to /private/tmp, so that refusal fires there. Write to an explicit
real path instead.
sow log prune
Deletes eligible terminal audit records older than BEFORE and safely compacts the database.
The absolute timestamp is echoed back so the local-timezone interpretation is never ambiguous.
| Flag | Description | Default |
|---|---|---|
-C, --workdir DIR |
Workspace discovery start directory | current directory |
-r, --repo NAME |
Select a repository | selection rules |
-T, --timeout DUR |
Maximum lock wait; 0 waits indefinitely |
0 |
-N, --no-wait |
Fail immediately when the lock is held | false |
--json |
Emit the versioned JSON envelope | false |
prune operates at Repository level and does not accept -d — pruning half an Operation would
produce a meaningless record.
BEFORE syntax
BEFORE is an ISO-8601 date YYYY-MM-DD, interpreted as local midnight, or an RFC 3339 timestamp
with a timezone.
What prune never deletes
prune is conservative by construction. It never removes:
- a non-terminal Operation;
- a record still required for recovery;
- current Package or Membership state;
- a Built Generation or its Changeset.
The pruned counter tells you exactly how many records were eligible, which is normally fewer than
the number of Operations older than the cutoff. Log and Changeset live in the same SQLite database,
but they follow different retention rules.
Examples
Investigate the most recent write:
List everything that failed:
Archive and shrink, monthly:
Which Operation last touched a Dist:
Exit codes
| Command | Code | Trigger |
|---|---|---|
log |
0 |
Records printed, including an empty ledger |
log |
2 |
Usage error (including a non-numeric Operation ID), Workspace not found, or ambiguous selection |
log |
5 |
State database unreadable |
log |
6 |
The given Operation ID does not exist |
log export |
0 |
Export written |
log export |
1 |
I/O failure writing the target, or the parent is not a real directory |
log export |
2 |
Usage error or ambiguous selection |
log export |
6 |
Target already exists |
log prune |
0 |
Prune completed, including pruning nothing |
log prune |
2 |
Malformed BEFORE, -d given, or ambiguous selection |
log prune |
4 |
Repository lock held and --no-wait given or --timeout expired |
log prune |
5 |
Integrity or recovery error |
See also
- Observability & Audit — how
logfits withstatus,checkandchanges - Transactions & Recovery — the journal that the log records
- sow changes — the physical counterpart to a semantic Operation
- JSON Output — the full log result schema