sow build / status / check / changes

Converge Desired state into a Built Generation, and the three commands that tell you where you stand.

One concept, one command. status is the cheap read, check is the full verification, build is the only thing that changes the public tree, and changes is the physical file diff between Generations. This page covers all four, because you almost always use them together.

Synopsis

sow status [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
sow build [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]
sow check [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
sow changes [BASE_GENERATION] [-C|--workdir DIR] [-r|--repo NAME] [--json]

The state model

Every Repository tracks two things: the Desired Revision in SQLite, and the Built Generation that the dists/ tree on disk corresponds to.

StateMeaningWhat clients see
cleanDesired matches BuiltEvery view current and complete
dirtyDesired is ahead — from --skip or a config changeThe old Built view, still complete
recoveringAn unfinished Operation exists; the next write command must recover firstThe last completed protocol pointer
errorAutomatic recovery cannot safely decide; needs a humanThe last completed view, never overwritten

Dirty never means a half-written index. Clients always follow a protocol pointer to a complete old or complete new view.

sow status

Cheap, read-only, no hashing. Reports Repository state, Desired Revision, Built Generation, dirty Dists, pending payload count and bytes, the most recent Operation, and lock state.

sow status
repository=pigsty status=clean ready_to_copy=true revision=11 generation=12 dirty_dists= pending=0/0 locked=false
sow status -r pgsql
repository=pgsql status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=trixie pending=4/2326 locked=false

ready_to_copy is the single field a sync script should read: it tells you whether pool/ + dists/ can be rsynced as-is right now.

status returns 0 in every readable state — clean, dirty, recovering and error alike — so scripts can consume structured state instead of parsing an error. Only an unreadable or unparsable state database produces a non-zero (integrity) exit. Use sow check when you want a hard gate.

sow status -r demo --json
{"schema":"sow.cli/v1","command":"status","ok":true,"repository":"demo","operation":null,"result":{"repository":"demo","status":"dirty","ready_to_copy":false,"desired_revision":5,"built_generation":4,"dirty_dists":["el9"],"dirty_reasons":["dist el9 Desired and Built membership sets differ","one or more dists differ from their built projections"],"pending":{"count":1,"bytes":19776},"recent_operation":{"id":"3329269325810066022","kind":"add","state":"done_dirty","created_at":"2026-08-04T04:10:22.481991Z","updated_at":"2026-08-04T04:10:22.553516Z"},"repository_locked":false},"errors":[]}

status never recovers anything, and it never reports a stale-but-self-consistent Generation as corruption.

sow build

Takes the Repository write lock, recovers any unfinished Operation, then converges the current Desired state into a new Built Generation.

sow build -r pgsql -d el9
{"operation":"4262183287563704350","repository":"pgsql","dists":["el9"],"desired_revision":6,"built_generation":6,"noop":false,"dirty":false}
FlagDescriptionDefault
-j, --jobs NParallel workerslogical CPU count
-C, --workdir DIRWorkspace discovery start directorycurrent directory
-r, --repo NAMESelect a repositoryselection rules
-d, --dist NAMESelect a distribution; repeatableall Dists
-T, --timeout DURMaximum lock wait; 0 waits indefinitely0
-N, --no-waitFail immediately when the lock is heldfalse
--jsonEmit the versioned JSON envelopefalse

Without -d, build converges every affected Dist of the Repository. With -d, only the selected ones converge and the rest stay dirty.

Like rm and show, build prints structured JSON on stdout even without --json.

No-op builds

If neither inputs nor renderer configuration changed, build does nothing and does not increment the Generation:

sow build
{"operation":"6295064788473690577","repository":"pigsty","dists":["el9","trixie"],"desired_revision":5,"built_generation":5,"noop":true,"dirty":false}

Policy convergence is one-way

build re-executes the current policy, so editing limit or exclude in sow.yml and running build is the supported way to apply it. Tightening a policy removes members. Loosening one does not reconstruct historical members from leftover pool bytes — re-run sow add.

Commit ordering

All metadata is staged on the same filesystem, verified and signed, then switched in. Protocol pointers — RPM repomd.xml, APT Release/InRelease — are replaced last, and checksum-named metadata plus APT by-hash guarantee that old and new clients never fetch a dangling reference.

One build Operation may cover several Dists. SOW does not promise that concurrent readers see all Dists flip at the same instant; it promises that each protocol view is always self-consistent, and that when the command returns every target belongs to the same Built Generation.

Recovery

build is the one explicit forward-recovery entry point. It first tries to complete or roll back any decidable non-terminal Operation. The error state is reserved for cases where journal, database and file evidence contradict each other and the tool cannot safely choose — there build refuses to overwrite, and you restore from backup before running check/build again. There is no repair --force that could guess wrong.

Metadata signing

Managed metadata signing comes only from sow.yml; there is no command-line override. An RPM architecture view always gets repodata/repomd.xml, plus an ASCII-armored repodata/repomd.xml.asc when signing.rpm.metadata.key is set. A DEB Dist always gets Release, plus a clearsigned InRelease and a detached Release.gpg when signing.deb.metadata.key is set. Changing a key reference or fingerprint makes the affected Dists dirty; the next build re-signs and produces a new Generation.

sow check

Full, read-only verification of the selected Repository and Dists, reported in eight layers.

sow check
repository=pigsty status=clean ready_to_copy=true revision=5 generation=5
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=67
package-bytes	ok=true	checked=8
desired-membership	ok=true	checked=8
index	ok=true	checked=2
signature	ok=true	checked=9
generation-manifest	ok=true	checked=5
LayerWhat it verifieschecked counts
configsow.yml parses and validates for this Repositoryconfiguration objects
stateSQLite quick_check, foreign keys, and journal/recovery evidencealways 1
public-modesFile and directory permissions across the served treeinspected paths
package-bytesSHA-256 of every pool and pending payloadpackage objects
desired-membershipMembership rows resolve to real objects under current policymemberships
indexRendered indexes match the membership they claimDists
signatureEvery declared signature verifiessignatures
generation-manifestThe Built Generation manifest matches the files on diskthe Generation number
FlagDescriptionDefault
-j, --jobs NParallel workerslogical CPU count
-C, --workdir DIRWorkspace discovery start directorycurrent directory
-r, --repo NAMESelect a repositoryselection rules
-d, --dist NAMESelect a distribution; repeatableall Dists
--jsonEmit the versioned JSON envelopefalse

check never repairs, never builds and never recovers an Operation.

Dirty is a check failure

When the Repository is dirty, check verifies both the Desired state and the old Built Generation — and then rules the tree not ready to copy, exiting 5:

sow check
repository=pigsty status=dirty ready_to_copy=false revision=6 generation=5
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=67
package-bytes	ok=true	checked=8
desired-membership	ok=true	checked=7
index	ok=true	checked=2
signature	ok=true	checked=9
generation-manifest	ok=true	checked=5
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

Every layer passed. Exit 5 here means “the old tree is intact but it is not what you asked for” — run build. That is exactly the gate you want in a release pipeline.

sow changes

Prints the physical file changes between two Built Generations, as a delivery plan.

sow changes
base=4 generation=5 dirty=false
add	payload	dists/el9/x86_64/pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm	19776	ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16
add	payload	pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm	19776	ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16
add	metadata	dists/el9/x86_64/repodata/5bc463cb00bec4d6185ea593a6fa8f180f24d3251b498f5bbeb14875581c33cc-primary.xml.gz	1460	5bc463cb00bec4d6185ea593a6fa8f180f24d3251b498f5bbeb14875581c33cc
update	pointer	dists/el9/x86_64/repodata/repomd.xml	1514	05d3d5bf0f9236626b22a8ae9c92853277fff506f5773fbc33316ea12683cf0b
delete	delete	dists/el9/x86_64/repodata/0df96f0b046b6c098398194f908cc99d90bf3af8c5f66d262b2e6d43a658a58f-primary.xml.gz	0

Columns are op, phase, repository-relative path, size and SHA-256. op is add/update/delete; phase is payload/metadata/pointer/delete. Apply them in phase order — payload first, pointer last, deletions after everything else — and no client ever sees a dangling reference.

FlagDescriptionDefault
-C, --workdir DIRWorkspace discovery start directorycurrent directory
-r, --repo NAMESelect a repositoryselection rules
--jsonEmit the versioned JSON envelopefalse

BASE_GENERATION

Without an argument, changes diffs the latest Built Generation against its predecessor.

changes 0 produces the complete delivery manifest for the current Built Generation — every file under pool/ and dists/, excluding sow.yml and .sow/:

sow changes 0
base=0 generation=2 dirty=false
add	payload	dists/el9/aarch64/pool/e/epel-release/epel-release-7-5.noarch.rpm	14524	d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab
add	payload	dists/el9/x86_64/pool/e/epel-release/epel-release-7-5.noarch.rpm	14524	d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab
add	payload	pool/e/epel-release/epel-release-7-5.noarch.rpm	14524	d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab
add	metadata	dists/el9/aarch64/repodata/fb3777fe0da404b2ac78b26566e1eec95a4fc90f04b322e52925fc9baebb2764-primary.xml.gz	797	fb3777fe0da404b2ac78b26566e1eec95a4fc90f04b322e52925fc9baebb2764
add	pointer	dists/el9/x86_64/repodata/repomd.xml	1511	16d334bc2b1c20c27aac9f3a353b97018a994e55ef45acc90fa50dcf5b8268a4

Out-of-range bases are rejected:

sow changes 99
operation rejected: managed: operation rejected: base generation 99 is outside 0..2

A Repository that has never built anything prints an empty plan:

sow changes -r empty
base=0 generation=0 dirty=false

Repository scope only

changes is a Repository-level Generation output and rejects -d. Filter by repository-relative path if you want one Dist:

sow changes -d el9
usage error: --dist is not allowed for changes

Dirty and recovering

A dirty Desired state does not enter changes. The output flags dirty=true and still ends at the current Built Generation — private pending payloads are invisible here, because they are not part of the deliverable tree yet:

sow changes -r demo
base=3 generation=4 dirty=true
add	metadata	dists/el9/aarch64/repodata/0df96f0b046b6c098398194f908cc99d90bf3af8c5f66d262b2e6d43a658a58f-primary.xml.gz	140	0df96f0b046b6c098398194f908cc99d90bf3af8c5f66d262b2e6d43a658a58f

When the Repository is recovering or error, changes refuses to emit a sync plan at all: pending file actions must not be mistaken for a Generation.

Examples

The standard bulk-import cycle:

sow add /srv/build/ -R -r pgsql -d el9 --skip
sow status -r pgsql
sow build -r pgsql -j 12
sow check -r pgsql

Gate a release on a fully verified tree:

sow check -r pgsql || { echo "not deliverable"; exit 1; }
rsync -a --delete /srv/repo/pgsql/ mirror:/srv/www/pgsql/

Hand an incremental plan to an external sync tool:

sow changes 41 -r pgsql --json > changes-41-current.json

Wait at most 30 seconds for another writer, then give up:

sow build -r pgsql -T 30s

Exit codes

CommandCodeTrigger
status0State readable — in clean, dirty, recovering and error alike
status2Workspace not found or ambiguous selection
status5State database unreadable or unparsable
build0Converged, or nothing to do
build1Renderer, signing or I/O failure
build2Usage error or ambiguous selection
build4Lock unavailable
build5Recovery could not safely complete, or the Repository is in error
build6Configuration rejects the current state, for example an architecture still in use was removed
check0All layers pass and the tree is ready to copy
check1I/O failure while verifying
check2Usage error or ambiguous selection
check5A layer failed, or the Repository is dirty and therefore not deliverable
changes0Plan printed, including an empty plan
changes2-d given, ambiguous selection, or Workspace not found
changes5Repository is recovering or error
changes6BASE_GENERATION outside the valid range

See also

Last modified: 2026-08-08: init commit (fe725aa)