Every shellcell package repository, published to https://dl.shellcell.dev with snailmail.
| repository | format | serves | URL |
|---|---|---|---|
releases |
raw | release binaries for every tool and platform | dl.shellcell.dev/releases/ |
apt |
deb | apt-get install for Debian and Ubuntu |
dl.shellcell.dev/apt/ |
yum |
rpm | dnf install for Fedora and RHEL |
dl.shellcell.dev/yum/ |
alpine |
apk | apk add for Alpine |
dl.shellcell.dev/alpine/ |
charts |
helm | Helm charts | dl.shellcell.dev/charts/ |
One repository per format, not per tool: raw publishes to
<name>/<version>/<file> and deb pools by package name, so every shellcell tool
lives in the same three.
Tools do not publish to this repository. Each one builds its own artifacts,
attaches them to a GitHub Release with a SHA256SUMS, and dispatches here:
shellcell/snailmail tag v0.1.2 ──► GitHub Release + SHA256SUMS
│ repository_dispatch
▼
postoffice: adopt (pins each digest) ──► plan ──► apply ──► gh-pages
bin/adopt-release.sh lists the release, reads whatever checksum file it
publishes, and adopts each asset by digest, routing it by what it is: .deb to
apt, .rpm to yum, .apk to alpine, .tgz to charts, archives to
releases. An asset type with no rule fails rather than landing somewhere
arbitrary.
The release is listed rather than read only through its checksum file. A producer that publishes digests for its tarballs but not its packages would otherwise adopt cleanly and publish half of itself.
SHA256SUMS, checksums.txt and the other common names are recognised;
--checksums NAME says which to use when a release names it something else.
An asset with no published digest is refused by name. Adopting it means pinning
the bytes that run downloads rather than a digest the producer stood behind,
which is a weaker claim about what was released — --pin-downloads accepts
that, and nothing else does it silently. The publish workflow exposes the same
choice as an input, and only on a manual run: an automatic dispatch never makes
it for you.
Adding a tool needs no change here. Give it a release workflow that produces
conventionally named assets and dispatches release with its repository and tag,
using a token with Contents: write on this repository (TAP_GITHUB_TOKEN).
Archives are the one format that cannot name itself. Raw reads
<name>_<version>_<os>_<arch>.<ext> out of the filename; an archive written as
tool-v1.2.3-linux-amd64.tar.gz is republished under the convention so its
architecture is recorded, and anything unrecognisable is adopted under the tool
and version alone.
bin/adopt-release.sh shellcell/snailmail v0.1.2
git diff repos/ # review what is about to be published
git commit -am 'adopt snailmail 0.1.2'
git push # the push publishesGH_TOKEN is optional, and raises the anonymous API rate limit when listing the
release.
main holds desired state — snailmail.toml, the locks, the publication
ledgers and deployment receipts. gh-pages holds the built site as a single
orphan commit, force-pushed each time so published bytes never accumulate in
history.
Artifact bytes are in neither. Each lock pins the origin on the producing repository's GitHub Release, and snailmail refetches from it and checks it against the committed digest. That is why this needs no object storage: your GitHub Releases already are the durable copy. It also means a release whose assets are deleted can no longer be rebuilt — keep them.
docs/ is generated by snailmail apply and is not committed.
snailmail status # what is desired, and what is published
snailmail check --origins # re-fetch pins and verify them
snailmail promote releases snailmail 0.1.2
snailmail yank --all releases snailmail 0.1.1
snailmail prune releases --keep 5 # bound how many versions stay visibleEvery one of these edits a lock. Publishing is always plan then apply, run
by the workflow on push.
The apt repository is signed by archive-signing, an OpenPGP RSA-4096 key. The
private key lives outside the workspace in
$XDG_DATA_HOME/snailmail/private-keys/<workspace-id>/, mode 0600; only the
public forms are committed, as keys/archive-signing.gpg and .asc.
Publishing needs three settings on this repository:
| setting | value |
|---|---|
vars.SNAILMAIL_SIGNING_KEY_REF |
53b4b8e5c2289f98d8b4b7537f94ae3facbb03934dc5363e4608bd27703c2837/archive-signing |
secrets.SNAILMAIL_SIGNING_PRIVATE_KEY |
contents of that .asc private key file |
vars.SNAILMAIL_APK_KEY_REF |
53b4b8e5…/alpine-signing |
secrets.SNAILMAIL_APK_PRIVATE_KEY |
contents of that .asc private key file |
secrets.SNAILMAIL_KEY_PASSPHRASE |
the passphrase, at least 24 bytes; one covers the store |
apt and yum share the OpenPGP key: both verify OpenPGP, so one key serves
them. alpine cannot — apk verifies a bare RSA key it holds by filename and can
check no other — so it has its own, and keys attach refuses the wrong kind
rather than letting it fail later at publication.
Locally, export SNAILMAIL_KEY_PASSPHRASE before plan or apply; without it
they refuse to run rather than publish something unsigned.
Rotation is a first-class operation — snailmail keys rotate apt --successor <key>, then --advance once the overlap has been served long enough for
clients to have refreshed. snailmail keys audit reports whether the current
state is valid.
snailmail setup only creates repositories, so signing one that was set up
unsigned is its own command:
snailmail keys new archive-signing --expires-in 17520h
snailmail keys attach apt --key archive-signing
snailmail keys auditattach derives the client keyring from the key the workspace already recorded,
and refuses a repository that is already signed — replacing a live key is
rotation, which serves both keys for an overlap so clients keep trusting the
repository through the change.
Publishing runs on a fresh runner every time, which needs
- Pages serving the
gh-pagesbranch, withdl.shellcell.devas the custom domain and aCNAMEDNS record pointing atshellcell.github.io - Docker on the runner, so a real
aptverifies the Debian repository by installing from it - full history on checkout, since ledgers are validated against it