Skip to content

Repository files navigation

snailmail

relaxed package delivery

One tool to create, host, sign and operate package repositories from a git-backed workspace. Builds are deterministic, plans are reviewable, artifacts are immutable, and before anything is published a real client — pip, apt, dnf, apk, helm — installs from the built tree in a container.

MIT licensed. See LICENSE and NOTICE.md.

Six formats today. Debian (apt), RPM (dnf/yum), Alpine (apk), PyPI (pip), Helm, and raw for artifacts that carry no ecosystem metadata. Debian, RPM, Alpine and Helm are signed with the scheme their own clients verify.

Three hosts today. A local directory, GitHub Pages, and S3-compatible object storage — though not every format on every host; see the table below.

Planned, not built: npm, OCI, Cargo, Go, Maven and Nix repositories, and publishing into registries you do not own (AUR, Homebrew, nixpkgs, npmjs, PyPI, ghcr). PLAN.md has the design; none of it works yet.

Quickstart

From an empty directory to a published PyPI repository:

go run ./cmd/snailmail init --name example   # creates the Git repository if there is none
go run ./cmd/snailmail setup pypi --name python --output public/python
go run ./cmd/snailmail add python ./dist/*.whl
git add .gitignore snailmail.toml repos/python.lock.toml
git commit -m "configure Python repository"
go run ./cmd/snailmail plan
go run ./cmd/snailmail apply --plan snailmail.snailmail-plan.json

plan writes what it intends to do; apply does it and records a publication ledger entry. Nothing reaches a host until a plan has been applied.

Each command prints the one that usually comes next, so the sequence can be followed without this page. The git commit in the middle is not incidental: desired state is reviewed as a diff, and plan reads committed state only.

apply reports each phase as it runs — a publication takes minutes, most of it building and running a real client per format — and writes that narration to stderr, so --json still emits exactly one document on stdout.

apply --dry-run builds, verifies and checks every gate, then stops before writing anything to a host. It is not the same as --structural-only, which only skips client verification and still publishes.

Every command that takes --workspace also accepts --root.

What runs where

Host pypi deb rpm apk helm raw
local directory yes yes yes yes yes yes
GitHub Pages yes yes yes yes yes yes
S3 / R2 / GCS yes unsigned only yes yes

An object store makes a revision live by writing one object, so it can serve a format only where a single path switches. Debian needs a Release and its detached signature to become live together; Alpine has one index per architecture; and a signed yum repository has to switch repomd.xml with the repomd.xml.asc that signs it. Those are structural limits, not missing work — a local directory or GitHub Pages commits a whole tree at once and serves all six.

One caveat for object storage: the browsable index.html is generated fresh for every revision, so it is kept with the release rather than at the repository root. Clients are unaffected — they fetch simple/, index.yaml, repodata/ or SHA256SUMS — but there is no human-browsable page at the root of a bucket-hosted repository yet.

Status

Phase 2 is complete and Phase 3 signing is implemented. Concretely: git-backed workspaces with init, setup, add, plan and apply; immutable blobs; reviewable plans; publication ledgers; per-repository managed release switching; auto, pr and signed-approval gates; encrypted local RSA4096 OpenPGP keys with keys new|publish|audit|rotate; receipt-backed Debian key rotation; and placement-level promote and yank.

Review gates read GitHub, GitLab, Forgejo and Gitea, and snailmail ci emits a GitHub Actions or GitLab CI pipeline derived from the workspace.

Replacing an existing managed release needs an atomic directory-entry exchange, so that path is implemented on Linux and macOS; creating a first release is portable. There is no Windows build yet.

Additional key backends, more formats, import, and an interactive setup remain Phase 3 work. ARCHITECTURE.md is the implementation contract and PLAN.md the broader design.

Promotions and yanks

Promotions and yanks edit only placement records. Package versions, blob bindings, and publication history remain available for later re-promotion:

go run ./cmd/snailmail promote --track testing python snail-demo 1.2.3
go run ./cmd/snailmail yank --track stable python snail-demo 1.2.3
# Or remove every placement for the exact version:
go run ./cmd/snailmail yank --all python snail-demo 1.2.3

git diff -- repos/python.lock.toml
git add repos/python.lock.toml
git commit -m "update Python package placements"
go run ./cmd/snailmail plan
go run ./cmd/snailmail apply

The repository and package version are exact; promotion does not copy package versions between repositories. Each repository renders only its configured --track (default stable), and Debian additionally renders only placements for its configured suite. Other placements remain recorded but are not exposed in that view. Removing the final visible placement publishes a valid empty index while retaining immutable package and blob records in Git. Debian defaults a new placement's distro to the configured suite; --distro DISTRO selects another distro coordinate explicitly.

Retention pruning removes only older placements, independently per package, track, and distro, using native PEP 440, Debian, or SemVer precedence:

go run ./cmd/snailmail prune python --keep 5
git add repos/python.lock.toml
git commit -m "prune old Python placements"
go run ./cmd/snailmail plan
go run ./cmd/snailmail apply

Versions tied at the retention boundary are kept together. Prune does not remove package-version records, CAS objects, remote blobs, or publication history; physical blob GC remains a separate future operation with tombstones and a grace period.

Auditing what is published

snailmail check is a read-only integrity audit of every retained package version, including yanked and pruned versions. It verifies local CAS bytes or fetches the configured S3 authority into temporary storage, reparses native package facts, and revalidates historical publication bindings. Upstream release discovery remains unavailable until releases are modeled. check --origins re-fetches explicitly adopted URLs and compares their pinned bytes; default checks remain offline from external sources. Origin checks process at most four sorted records per run; --origin-offset selects subsequent batches.

Importing an existing repository

Most people arrive with a repository already published somewhere. import reads its index and records every artifact it names, rather than adopting each by hand:

snailmail import --project six --public-origin --dry-run python https://pypi.org/
snailmail import --project six --public-origin python https://pypi.org/

Each artifact goes through the same path as adopt: fetched, checked against the digest its index published, and recorded with its origin URL so it can be refetched later.

What a recorded digest is worth depends on where it came from — an index someone signed is not the same as one served over TLS alone — so the lock records that beside it, and status --json reports the counts:

  python -> {"index-stated": 2}

index-stated means the index published the digest and the fetched bytes matched, which is the strongest a simple index supports. Artifacts recorded by adopt read as operator, including in locks written before this field existed. PLAN.md §3.8 has the full set and what each level is worth. Anything the index names but does not publish a SHA-256 for is skipped and reported — a locked artifact is pinned to a digest someone stated in advance, and one computed from the bytes a download happened to return would prove only that the download was self-consistent.

One artifact failing does not abandon the rest, so a repository with a broken file imports the other 47 and names the one that failed.

PyPI and Helm today. A Helm repository has no per-project page, so importing one imports the repository:

snailmail import --public-origin --dry-run charts https://grafana.github.io/helm-charts

Where an index lists several mirrors of a chart, the origin recorded is the URL that actually served. Where it lists the same name and version twice, both entries are skipped and named — two entries claiming one identity cannot both be it, and picking one would record bytes nobody chose.

Debian too, which reads a suite rather than a project:

snailmail import --public-origin --dry-run --suite bookworm apt https://deb.debian.org/debian

A Debian import walks the chain rather than trusting the leaf: Release states the digest of Packages, and a Packages whose bytes disagree is refused outright — nothing in an index that failed its own root can be trusted. That is why Debian artifacts record index-chain where PyPI and Helm record index-stated. The Release signature itself is not verified yet, so the root of trust is still the transport, and the lock says exactly that.

And yum, which needs no extra flags because a repository root is one repository:

snailmail import --public-origin --dry-run rocky https://dl.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os

yum walks the same chain as Debian: repomd.xml states the digest of primary.xml.gz, and a primary whose bytes disagree is refused. So rpm artifacts also record index-chain. If repomd.xml states only a sha1 or md5 for its primary, the import stops rather than quietly recording a weaker provenance than was asked for — signing repomd.xml is what would raise this to signed-index.

Multilib is preserved: i686 and x86_64 builds of one name-version are two artifacts, recorded as two blobs under the same package version rather than one overwriting the other.

And Alpine, which is the honest exception:

snailmail import --public-origin --dry-run alpine https://dl-cdn.alpinelinux.org/alpine/v3.19/main/x86_64

An APKINDEX entry carries C:Q1…, which decodes to a SHA-1 of the package's control section — not of the file. Checked against Alpine's own archive: the index states 6026787b… for 7zip-23.01-r0.apk, whose actual SHA-1 is 76a96042…. They differ because they are digests of different things. So there is nothing in an Alpine index to pin an artifact to, and an imported apk records computed: a digest of the bytes snailmail downloaded, and nothing more.

That is allowed but never hidden. If your workspace will not accept unauthenticated bytes, say so once:

snailmail import --public-origin --min-provenance index-stated alpine https://…

Every artifact then reports why it was refused, rather than being pinned to something weaker than you asked for. The floor works for any format — a Debian import establishes index-chain, so it passes an index-stated floor.

Adopting an artifact from a URL

snailmail adopt --sha256 HEX --public-origin REPOSITORY URL records one explicitly selected artifact in an existing owned repository. The lowercase SHA-256 pin is mandatory; --public-origin confirms that the complete requested URL is non-secret and may be committed and printed. Lock schema 2 retains that URL, plans display every visible adopted acquisition, and --dry-run validates without changing CAS or lock state. An adopted artifact is streamed to disk rather than held in memory, so its size costs disk and time rather than resident memory; SNAILMAIL_MAX_ARTIFACT_BYTES raises the 2 GiB ceiling that remains. Adoption requires the local blob store and does not claim authorship, build provenance, source signatures, or historical snailmail publication.

Inspecting a workspace

snailmail status also reports size: each repository's lock in bytes, the count and total size of the distinct artifacts it binds, and the workspace's lock total with the largest repository named. The lock is parsed whole on every plan and every apply, so its size is the number that predicts where a workspace stops being comfortable — and which repository to split when it does.

snailmail status reports committed workspace evidence without contacting hosts or providers. Human output summarizes visible and retained versions, visible publication-binding completeness, and whether a managed deployment receipt is recorded; --json emits the same deterministic schema for automation. A receipt is evidence of a prior successful apply, not proof that a host currently serves those bytes.

snailmail rollout answers when each version reached a client, derived from the publication ledger rather than stored anywhere. The ledger already records one append-only entry per publication, so the date is read back rather than kept a second time. A version is listed with the date it was first published, the number of published trees that have carried it, and whether the repository still serves it; --withdrawn includes versions that were published and later yanked or pruned, because publication is immutable even when the offer is not.

snailmail ci github and snailmail ci gitlab emit a pipeline that publishes this workspace, to stdout rather than to a file: it carries decisions snailmail cannot make — which registry to pull verification images through, which secret names a project uses — and a file the tool owned would be rewritten over an operator's edits. What it does derive is what the workspace already says: which signing keys need materialising, whether a foreign architecture needs emulation, and which repositories publish into a directory something else must serve. Both providers derive the same facts; only the rendering differs, and it differs more than wording. A GitLab runner has no Docker daemon, so client verification needs one as a service; its jobs share no filesystem, so what apply builds is declared as an artifact; and Pages there serves a job artifact rather than a moved ref, so there is no orphan commit.

Two runners at once

The workspace lock is a file inside the checkout, so it serialises operations on one machine and not two. What protects a repository when two CI runners publish at the same time is the conditional commit: each states the revision it observed, and the second to arrive is refused because that is no longer what is live.

So the failure mode is one run fails, not one run waits. It fails as a stale plan, which is a retry signal — replanning against what is actually live and applying again succeeds. A pipeline that publishes from more than one place at once wants a concurrency group so this is rare rather than routine; the generated workflows set one.

Two things worth knowing. Staging is not exclusive, deliberately: two runners can both stage, because a stage writes only under its own identifier and touches nothing a client reads. And a local host cannot be shared at all — its output path is relative to its workspace — so this only arises for object storage and Pages, where two runners can name the same bucket and prefix or the same repository and branch.

How large a workspace can get

A repository lock is parsed whole on every plan, apply, status and check, so its size is what decides how long those take and how much memory they need. Measured at roughly 385 bytes per package-version, with parsing costing about one and a half times the file in heap:

package-versions lock file parse heap
2,000 0.8 MB 5 ms 1 MB
20,000 7.7 MB 44 ms 12 MB
100,000 38.5 MB 226 ms 59 MB

snailmail refuses a lock over 128 MiB — about 330,000 package-versions — and says so, naming the repository, the size and what to do:

repository "repos/apt.lock.toml" has a 200 MiB lock, over the 128 MiB limit, and
parsing it needs roughly 300 MiB of memory on every plan and apply: prune retained
versions, split the repository, or set SNAILMAIL_MAX_LOCK_BYTES to proceed anyway

That is the honest state of things: one lock per repository is the current design, and a workspace past this size wants the lock sharded per package rather than a larger limit. The limit exists so that a workspace which has outgrown the design finds out in a sentence instead of getting slower until something is killed. snailmail status reports every lock's size, so the number can be watched before it becomes a refusal.

Artifacts themselves are not held in memory — they stream from content-addressed storage — so the ceiling is index and lock size, not repository size. Generated indexes are held whole, which for Debian and yum is the binding constraint at around a million package-versions.

Past 2,000 package-versions a repository's lock is written as one file per package, under a root that indexes them and a Merkle digest over the set:

repos/apt.lock.toml     the root: schema, placement, shard index, Merkle root
repos/apt.lock.d/3f/…   one file per package

Nothing about a lock's meaning changes — it still has a single identity that a plan pins, and a shard edited on its own is refused by name rather than accepted quietly. What changes is what a publication costs. Adding one version rewrites one small file instead of the whole lock, so a reviewer sees the package that changed rather than a multi-megabyte diff. Measured at 10,000 packages: adding a version takes 108 ms and reading the lock 102 ms, against 3.4 s to write when every shard is compared and 950 ms to read them one at a time.

The transition happens once, automatically, when a repository grows past the threshold, and a lock never goes back to one file — returning would itself be a diff rewriting everything. The first sharded write of a large repository creates every file and takes seconds; subsequent ones do not.

One workspace, or one per team

Use one workspace for the whole organisation unless you have a reason not to.

It sounds like fifty teams would contend on one review queue and one lock, and they do not. Each repository has its own lock file, so two teams publishing to two repositories touch two different files and git merges them; snailmail.toml is the only shared file, and it changes when a repository is configured rather than when one is published to. Point CODEOWNERS at repos/<name>.lock.toml and each team reviews its own publications. The workspace lock covers a single checkout, so CI runners with their own checkouts never wait on each other — what orders two concurrent publications is the host refusing the second, per repository.

Split when one of these is true:

  • A team needs its own blob store. A plan is bound to one, so a workspace has exactly one.
  • A team cannot share read access to the state repository, because a workspace is a git repository and access to it is all-or-nothing.

Separate workspaces can still publish into the same bucket under different prefixes. What you lose is the shared view: snailmail site describes one workspace, so splitting means several index pages and no single answer to where a package is published.

Publishing over ssh

The rsync host publishes to a directory on a machine you reach over ssh — the shape most people already have, where a web server serves a filesystem path.

snailmail setup rpm --name yum --host rsync \
  --target deploy@packages.example \
  --output /srv/www/yum \
  --base-url https://packages.example/yum

--output is the absolute path on the far side, where for a local host it is workspace-relative. --target is the ssh destination; port, key and jump host belong in ssh_config.

This is the only host that serves every format, including a signed yum repository. A revision goes live by renaming one symlink, so the whole tree switches at once and the number of files that must change together does not matter. An object store commits one object and no more, which is why it cannot serve Debian, Alpine, or signed yum at all.

Two POSIX primitives carry the guarantees, neither of them from the transport:

  • rename(2) makes a publication atomic. The published path is a symlink into .snailmail/releases/<tree>/, and a revision goes live when a new symlink is renamed over the old one. A client follows one whole revision or the other, never a half-copied tree. The tree is copied to a staging path and renamed into its release first, so the symlink never points at a partial release.
  • mkdir(2) makes it conditional. There is no compare-and-swap on a symlink, so the expected revision is checked and the swap performed while holding a lock directory whose creation fails if it already exists. Two runners publishing at once means one fails, not one waits — the same answer every other host gives.

snailmail collect works here too. Every revision leaves a tree under .snailmail/releases/, and the swap that makes a new one live does not remove the one it replaced. Collection keeps the live revision — read from the far side, not trusted from the workspace — and whatever retention names. Unlike object storage there is no restore target held back, because rollback is not offered, so an older revision survives only if you ask for it.

Requirements and limits, stated because they are not checked from this side:

  • The published path and its release directory must be on one filesystem, since rename is not atomic across mount points.
  • ssh options — port, key, jump host — belong in ssh_config. A second place to configure ssh is a second place for it to be wrong.
  • A path that already exists and was not published by snailmail is refused rather than replaced, so a publication cannot unpublish somebody else's files.
  • Rollback is not offered. Pointing the symlink at an earlier release is easy; establishing that the release is still intact is not, because nothing on the far side verifies it. The adapter declines rather than claiming a rollback it cannot check.

Browsing a bucket-hosted repository

A repository published to object storage serves index.html at its root, so https://packages.example/apt/ opens in a browser and shows what is published, how to install it, and each artifact's digest.

That page is a convenience copy and is not covered by the publication's guarantees. Nothing verifies it, observe does not read it, and a rollback does not restore it — it is refreshed by the next publication. The verified copy lives inside the release directory, because it is regenerated for every revision and writing it canonically would leave the previous revision unverifiable after a rollback. Clients are unaffected either way: they read simple/, index.yaml, repodata/ or SHA256SUMS, and those are the complete, verified answer.

The page shows the 500 most recently published artifacts and says so when there are more. A rendered row costs about 610 bytes, so a Debian suite of 63,440 artifacts would otherwise be a 38 MB page rebuilt and re-uploaded on every publication. Set it with Window if you want a different size; the footer always reports the repository's true total.

Undoing a publication

When a publication succeeded and turned out to be wrong:

snailmail rollback apt            # report what would be restored
snailmail rollback apt --yes      # restore the previous publication

One step, deliberately. A published revision carries a reference to the root it replaced, and collection protects that target for exactly this reason — so going back one publication is something the host can still verify. Going back further is not offered: nothing keeps a chain of restore references, and the older releases may have been collected. A rollback whose target cannot be checked is not a rollback.

Not every host can do it. A local directory and an ssh host both decline, because neither can establish that the release it would point back at is intact. There the answer is to revert the lock in git and publish forward, and rollback says so rather than failing obscurely.

Your lock still describes what you rolled back from. That is deliberate — the rollback changed what is served, not what your workspace wants — but it means the next apply will republish what you just undid unless you revert or amend the lock first. The command says this every time it runs.

Collecting superseded releases

An object store keeps every revision it has ever published: a publication writes a whole tree under .snailmail/releases/<tree>/, and nothing removes the previous one. A project publishing daily accumulates a copy a day.

snailmail collect                 # report what would be removed
snailmail collect --keep 3 --yes  # remove it, retaining three recent revisions

Reporting is the default; --yes deletes. Three things always survive whatever --keep says: the live revision, whatever its rollback depends on, and any revision the ledger records within --keep. The first two are established by reading the host rather than the workspace, so a checkout whose ledger is behind cannot delete what is being served.

Note that with only two publications nothing is collectable — both are protected, the live one and the one it rolls back to. Local directories and GitHub Pages have nothing to collect and say so: the first leaves nothing behind, and the second leaves unreachable objects to git.

Collection is the one operation that needs s3:ListBucket, so it may run under a different credential from publishing.

Two things accumulate, and they are collected together. Release directories are one; the other only exists for helm and unsigned rpm, which write their artifacts at the paths clients fetch them from rather than inside a release directory — because index.yaml and repomd.xml name those paths, and rewriting a signed rpm would invalidate it. A chart dropped from the workspace a year ago is still a billable object that no release collection has ever touched. collect now removes those too, keeping every file that any surviving revision publishes.

Two revisions are always protected: the live one and the one its restore rolls back to. So the first collectable revision is the live one's grandparent, which is worth knowing before reading a --dry-run and concluding it reclaims less than expected. If a surviving revision's release descriptor cannot be read, the whole collection is refused rather than run against an unknown reference set — deleting too little costs storage, deleting too much costs the repository.

How much a repository keeps is part of its configuration, not a flag someone remembers:

snailmail setup deb --name apt --keep 20 ...

collect uses that, and --keep N overrides it for one run. The order matters: collection is the only operation here that deletes published bytes, so the policy belongs in the reviewed manifest where changing it is a diff, exactly like changing a gate or a signing key. A repository that declares nothing keeps the default of 10, so nothing configured before this behaves differently.

Inspecting someone else's repository

snailmail doctor URL needs no workspace and inspects a public HTTPS PyPI, Debian, or Helm repository. It parses bounded native indexes, follows at most the configured artifact limit, and checks referenced availability, size, SHA-256, archive validity, and package identity. Use --project for PyPI artifacts and --suite for a Debian base URL. Debian Release signatures and Helm provenance are explicitly reported as unverified in this initial slice. Runs inspect at most four artifacts, cap each expanded archive at 64 MiB, and stop after two minutes.

Raw repositories

raw repositories publish artifacts that carry no ecosystem metadata: release tarballs, static binaries, installers. Every other format reads a package name and version out of the bytes; raw cannot, so identity comes from the filename by convention, or from you when the convention does not apply.

go run ./cmd/snailmail setup raw --name tools --output public/tools

# <name>_<version>[_<os>][_<arch>].<ext> is read without flags.
go run ./cmd/snailmail add tools ./dist/ttysvg_0.1.2_linux_amd64.tar.gz

# Anything else needs identity supplied, including a name with an underscore,
# which cannot be told apart from an extra field.
go run ./cmd/snailmail add --name ttysvg --version 0.2.0 tools ./dist/build-final.bin

go run ./cmd/snailmail plan && go run ./cmd/snailmail apply
go run ./cmd/snailmail verify raw --repo public/tools

Artifacts are published at <name>/<version>/<filename> beside a generated SHA256SUMS and index.html. Identity lives in the path on purpose: because it may have come from a flag rather than from the bytes, the published tree has to record it somewhere a later reader can check without knowing what was typed. SHA256SUMS is the interchange format — sha256sum -c verifies a raw repository with no snailmail present. Raw has no signing: it is not an ecosystem, so no client knows to check a signature.

Signing keys

Signed Debian repositories use an encrypted private key outside the workspace and commit only canonical public forms. Set a passphrase through the environment (never an argument), generate the key, and reference it during setup:

export SNAILMAIL_KEY_PASSPHRASE='use-a-secret-manager-value'
go run ./cmd/snailmail keys new archive-signing --expires-in 17520h
go run ./cmd/snailmail setup deb \
  --name debian \
  --output public/debian \
  --suite stable \
  --architectures amd64 \
  --signing-key archive-signing
go run ./cmd/snailmail keys audit
git add snailmail.toml keys/ repos/debian.lock.toml
git commit -m "configure signed Debian repository"
go run ./cmd/snailmail plan
go run ./cmd/snailmail apply

The file backend stores encrypted private keys under $XDG_DATA_HOME/snailmail/private-keys (or ~/.local/share/snailmail/private-keys) with mode 0600. Passphrases must contain at least 24 bytes and should come from a secret manager. In a container, prefer SNAILMAIL_KEY_PASSPHRASE_FILE pointing at a mounted file: a container's environment stays readable through the runtime's inspection API for as long as the container exists, whereas a file can be read once and removed. Setting both variables is rejected rather than resolved silently. keys publish recreates missing public forms after validating the private identity. Planning compiles content-addressed InRelease and Release.gpg signing nodes over the exact Debian Release bytes, signs each node twice to prove deterministic backend behavior, verifies both signatures, and embeds only public node responses in the reviewed plan. Apply does not load the private key. Signed client verification uses apt's signed-by; migrated unsigned Debian repositories remain readable but keys audit reports them as errors. New unsigned Debian setup requires the explicit --allow-unsigned compatibility opt-out.

Every format the knowledge bundle records as signable is signed, each in the form its own clients read: Debian publishes InRelease and Release.gpg, yum an armored key beside a detached repomd.xml.asc, Alpine one signature per architecture index under the key filename its index names, and Helm a .prov per chart beside the archive it covers. The published key differs with the client — apt installs a binary keyring, dnf imports an armored export, apk holds a bare RSA key by filename — so keys attach refuses a key whose algorithm the format's clients cannot check. Every signature is verified before it is published, because a repository carrying one that does not check out is worse than an unsigned one: a client reports it as tampering.

Rotating a signing key

Debian rotation keeps one active InRelease signer and a stable binary keyring. Introduction publishes both identities while the old key continues signing; activation switches to the successor only after the introducing deployment receipt has aged through the minimum refresh window; retirement removes old trust only after a second deployed overlap window:

go run ./cmd/snailmail keys rotate debian \
  --successor archive-signing-2027 \
  --minimum-refresh 720h
git add snailmail.toml keys/
git commit -m "introduce successor archive key"
go run ./cmd/snailmail plan && go run ./cmd/snailmail apply

# After `keys audit` reports the introducing state ready:
go run ./cmd/snailmail keys rotate debian --advance --yes
git add snailmail.toml && git commit -m "activate successor archive key"
go run ./cmd/snailmail plan && go run ./cmd/snailmail apply

# After the activated overlap is ready:
go run ./cmd/snailmail keys rotate debian --advance --yes
git add snailmail.toml && git commit -m "retire old archive key"
go run ./cmd/snailmail plan && go run ./cmd/snailmail apply

The minimum window is seven days. Its clock starts from the canonical post-verification deployment receipt, not from the manifest edit or plan time. Repository-hosted keyrings do not update clients automatically: refresh the local /usr/share/keyrings file through configuration management or a keyring package before activation. In CI, replace SNAILMAIL_SIGNING_KEY_REF and its encrypted private-key secret with the successor before planning the activated state; apply still receives no private key.

What plan requires

plan requires the manifest, configured locks, publication ledgers, and deployment receipts to be committed in a complete, non-shallow Git repository. apply executes the reviewed plan without replanning, verifies staged repository bytes, commits its exact publication ledger records with a compare-and-swap, and publishes through the selected host's conditional release switch. After canonical verification succeeds, apply commits a deployment receipt separately from the pre-effect publication ledger. S3 keeps the verified tree under an immutable digest prefix and conditionally updates a small root index that points clients at that tree.

Publishing to object storage

Object storage serves PyPI, Helm and unsigned yum repositories. Setup uses the standard AWS credential chain; no credential values are written to the manifest or plan:

go run ./cmd/snailmail setup pypi \
  --name python \
  --host s3 \
  --bucket example-packages \
  --prefix python \
  --region us-east-1 \
  --base-url https://packages.example.com/python
git add snailmail.toml repos/python.lock.toml docs/install-python.md
git commit -m "configure hosted Python repository"
go run ./cmd/snailmail plan
go run ./cmd/snailmail apply

What the credential has to be allowed to do

Publishing needs, scoped to the configured prefix:

  • s3:GetObject — reading back what it wrote, to verify it
  • s3:PutObject — writing artifacts, indexes and the root object
  • s3:DeleteObject — removing an abandoned stage, and removing the root object when a restore has to leave a repository with none

It does not need s3:ListBucket. Every operation on the publishing path addresses an object by a key snailmail already knows, which is what lets a publication be verified without trusting a listing.

s3:ListBucket is needed only to discover state a publication has superseded — collecting old releases. That is a separate operation and may run under a separate credential, so a publishing role stays as narrow as the list above.

The bucket or gateway must serve the configured prefix, including .snailmail/stages/ during pre-publication verification. Use --endpoint and --use-path-style for compatible object stores. All non-loopback S3 API and package client endpoints must use HTTPS. Configure a bucket lifecycle rule to expire abandoned .snailmail/stages/ objects after a grace period; immutable .snailmail/releases/, .snailmail/manifests/, and .snailmail/restores/ objects must not use that short-lived rule.

A private object-storage repository uses a Basic-auth gateway and a short-lived credential broker. The broker is a compiled executable selected at runtime by SNAILMAIL_CREDENTIAL_BROKER; set --visibility private --read-auth basic --credential-broker default during setup. Snailmail sends the reviewed workspace, host, plan, change, tree, and object-prefix scope as JSON on stdin. The helper returns username, password, and RFC3339 expires_at JSON, with a maximum lifetime of 15 minutes. The helper and gateway are trusted to enforce the supplied scope. The helper receives only selected profile, web-identity, TLS, and SNAILMAIL_BROKER_* environment variables, not the complete snailmail environment. Credentials stay out of Git, plans, URLs, and argv; pip receives them through an isolated temporary netrc that is destroyed after verification.

Shared S3 blob storage keeps Git locks provider-neutral while treating the local CAS as a verified disposable cache:

go run ./cmd/snailmail blob-store s3 \
  --bucket example-artifacts \
  --prefix snailmail/cas \
  --region us-east-1

Publishing to GitHub Pages

Public GitHub Pages requires distinct pre-provisioned production and preview sites that deploy the root of the configured branch. Authenticate gh, then:

go run ./cmd/snailmail setup pypi \
  --name python \
  --host github-pages \
  --github-repo example/packages \
  --github-preview-repo example/packages-preview \
  --base-url https://example.github.io/packages \
  --preview-url https://example.github.io/packages-preview

Pages publication uses exact orphan commits, immutable stage and restore refs, force-with-lease compare-and-swap, .nojekyll, and bounded propagation polling. Private Pages repositories are rejected.

Review and approval gates

For a PR gate, initialize the workspace with its reviewed state repository and configure --gate pr. Apply verifies that the exact Git revision was merged by a PR and remains reachable from that repository's default branch.

go run ./cmd/snailmail init --name example --forge-repo example/state

# On a forge other than GitHub, name it. The reference shape alone cannot say
# which service to ask whether a revision was reviewed, and omitting this means
# github.com.
go run ./cmd/snailmail init --name example \
  --forge gitlab --forge-repo acme/platform/state
go run ./cmd/snailmail init --name example \
  --forge gitea --forge-repo acme/state --forge-host git.acme.example

Approval gates use Ed25519 evidence. Keep the generated private key outside the workspace; commit only the printed public key in repository configuration:

go run ./cmd/snailmail approval-key generate --out ../snailmail-approval-key.json
go run ./cmd/snailmail setup pypi --name python --output public/python \
  --gate approval --approval-keys BASE64_PUBLIC_KEY
go run ./cmd/snailmail plan
go run ./cmd/snailmail approve --repository python \
  --key ../snailmail-approval-key.json --yes
go run ./cmd/snailmail apply

approve signs the exact plan ID, repository, key identity, and expiry. Apply reauthorizes before every stage, commit, and compensating restore.

Status pages

Render the read-only public matrix and machine-readable status from committed locks, ledgers, deployment receipts, and an optional current plan:

go run ./cmd/snailmail dashboard --output site

Build layout and binary size

plan and apply build and stage under .snailmail/stage in the workspace rather than in TMPDIR, so artifacts hard-link from the local CAS instead of being copied and large trees are never held in a tmpfs. Set an absolute SNAILMAIL_STAGE_DIR to stage elsewhere.

The AWS SDK is the largest single component of the binary. Builds that only target local directories or GitHub Pages can exclude it:

go build -trimpath -ldflags="-s -w" -tags nos3 ./cmd/snailmail

That drops the stripped binary from about 20.6 MB to 12.8 MB. S3 hosts and S3 blob stores then report that the build has no S3 support; every other format, host, and command is unaffected. The default build keeps S3.

Container image and CI examples

Dockerfile builds the runtime image. examples/github-actions.yml is a pinned workflow template to copy into your own workspace repository, with read-only PR testing and a protected default-branch apply job; this repository is not itself a snailmail workspace yet, so the template is linted here rather than run. Configure AWS_ROLE_ARN/AWS_REGION repository variables for OIDC-backed S3 access. Cross-repository Pages writes need SNAILMAIL_GITHUB_TOKEN. Approval jobs need SNAILMAIL_APPROVAL_REPOSITORIES and the SNAILMAIL_APPROVAL_PRIVATE_KEY secret. Private S3 requires an organization image containing its compiled broker plus the corresponding runtime variables. Signed Debian planning additionally uses SNAILMAIL_SIGNING_KEY_REF as the non-secret <workspace-id>/<key-name> repository variable and the SNAILMAIL_SIGNING_PRIVATE_KEY and SNAILMAIL_KEY_PASSPHRASE secrets. The workflow materializes that encrypted key only in runner temporary storage and does not pass it to apply.

go run ./cmd/snailmail build pypi --input ./dist --output ./repository
go run ./cmd/snailmail verify pypi --repo ./repository
go run ./cmd/snailmail build deb --input ./dist --output ./apt-repository
go run ./cmd/snailmail verify deb --repo ./apt-repository
go run ./cmd/snailmail build helm --input ./dist --output ./helm-repository
go run ./cmd/snailmail verify helm --repo ./helm-repository
go run ./cmd/snailmail serve --repo ./repository

The short version:

  • Static-first. Index generation is a pure function returning a file tree, so GitHub Pages, S3, or a USB stick are all valid hosting. The server is optional and never load-bearing.
  • Git is the state. A repository's contents are a committed lockfile; the served index is a build artifact. Rollback is git revert.
  • Declarative. One manifest says what should be published where; plan and apply reconcile against it.
  • Scriptable. Every command that reports a result accepts --json, so CI is the CLI in a container.
  • Gated per repository. auto, provider-bound merged-PR evidence, and plan-bound Ed25519 approval evidence share the same apply graph.
  • Verified. Apply verifies staged bytes structurally and, unless explicitly disabled, with the ecosystem client before switching the local target.
  • Local, S3, and Pages. snailmail setup records deterministic local targets, public or private object storage, or public GitHub Pages with a separate preview site.
  • Plan-resolved signing. Debian signing keys remain outside Git; exact verified InRelease and Release.gpg responses are reviewed in the plan and replayed without signer access during apply.
              apt      dnf      apk      aur      aur-bin  brew     nixpkgs
  ttysvg      0.1.2    0.1.2 ✗  0.1.2    0.1.2    0.1.2    0.1.2    0.0.7 ⚠
  exex        0.3.2    0.3.2 ✗  0.3.2    0.3.2    0.3.2    0.3.2    —
  cnvrt       0.0.3    0.0.3 ✗  0.0.3    0.0.3    0.0.3    0.0.3    —
  snailrace   0.0.5    0.0.5 ✗  0.0.5    0.0.5    PR #12   0.0.5    —

  ✗ verify failing   ⚠ lagging   PR # gate pending

About

Relaxed package delivery

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages