Signing and publishing¶
A bundle is a directory. Publishing it means summing it, signing it, packing it, and putting it somewhere your users can reach.
Three commands, in this order, and the middle one is not morzer's:
morzer release build ./my-product
minisign -Sm ./my-product/SHA256SUMS
morzer release archive ./my-product
If your release carries its own images, morzer release
pack ./my-product comes first — it copies them out of your registry into the
bundle, and build then sums the result.
The order is forced rather than chosen. The signature is a file inside the bundle — a sibling would not survive being packed and unpacked — so it has to exist before the bundle is packed. And morzer does not sign, so the step in the middle is yours.
Build it¶
morzer release build ./my-product
Writes SHA256SUMS over every file in the bundle, then verifies the result the
same way release verify does, so a broken bundle fails on your machine rather
than on a customer's.
It writes in place: the bundle directory is modified. Your CI checks out fresh so it never notices; running it in a working tree leaves a file to commit or ignore.
A bundle that already carries a signature is refused, because regenerating the
list necessarily invalidates any signature over it. --force discards the
signature rather than building around it — keeping one that no longer verifies
produces exactly the artifact the chain exists to prevent.
Sign it¶
The signature covers the SHA256SUMS that lists every other file. Two small
steps, each checkable by hand with standard tools, rather than one bespoke
signature only this program can verify.
minisign -Sm ./my-product/SHA256SUMS
That leaves both files inside the bundle:
my-product/
├── SHA256SUMS
├── SHA256SUMS.minisig
└── …
Inside rather than beside, so the signature survives being packed into an archive and unpacked again on the other machine.
The list has to be complete. A bundle that ships a SHA256SUMS naming only some
of its files is refused, naming the ones it left out — an unlisted file is a
file the signature does not cover, and the file an attacker adds is a file
nobody listed. release build produces a complete list by construction.
Pack it¶
morzer release archive ./my-product
# wrote demo-1.3.0.tar.zst
tar.zst is the format the manager reads. A bundle and its archive produce the
same content digest, so a digest you record from the directory verifies the
archive and vice versa — publishing does not change what a release is.
Two archives of the same tree are byte-identical, and SOURCE_DATE_EPOCH is
honoured. See entry order and reproducibility
for what that costs and what it buys.
If you roll your own tar¶
Supported, and sometimes necessary — a pipeline that cannot run morzer still has to produce a bundle. Two rules it must keep, both of which the commands above keep for you:
cd ./my-product
find . -type f ! -name SHA256SUMS ! -name SHA256SUMS.minisig \
-exec sha256sum {} + | sed 's| \./| |' > SHA256SUMS
minisign -Sm SHA256SUMS
{ echo manifest.yaml
find . -type f ! -path ./manifest.yaml | sed 's|^\./||' | LC_ALL=C sort
} > ../entries.txt
tar --zstd -cf ../demo-1.3.0.tar.zst --no-recursion -T ../entries.txt
manifest.yaml must be the first entry. The extraction budget is read from
the manifest before anything large is extracted, which only works while it
arrives first — so an archive that begins with anything else is refused,
whatever it contains. tar -C ./my-product . emits entries in directory order,
which no tar implementation specifies, and is the usual way to get this
wrong.
The checksum list must be complete. If your pipeline writes one by hand,
verify it with sha256sum -c SHA256SUMS and by comparing its line count
against the same file set the list covers:
find . -type f ! -name SHA256SUMS ! -name SHA256SUMS.minisig | wc -l
wc -l < SHA256SUMS
Nothing but regular files and directories, either way. Symlinks, hardlinks and device nodes are refused at extraction, so an archive containing one will not install.
The key¶
minisign -G -p morzer-bundles.pub -s morzer-bundles.key
The private half belongs in your release pipeline and nowhere else. There is no
morzer sign command on purpose: building signing into the manager would invite
the signing key onto a deployment host, which is the one machine it should never
be on.
Publish the public half where your users will look for it. They put it in their installation:
policy:
require_signature: true
signing_keys:
- RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3
Say what a signature means, and what it does not
It proves a bundle came from the holder of your key. It does not prove the bundle is safe to run — hooks execute as root on your users' machines either way. Signing narrows who can hand them a release; it does not narrow what a release can do. Do not let your documentation imply otherwise.
Check it before you ship it¶
morzer release verify ./my-product --signing-key RWQf6LR…
Validates the manifest, checks every referenced file exists, verifies the
SHA256SUMS against the contents, and checks the signature. It needs no
installation on the machine, so it belongs in your CI as a required check.
- run: morzer release verify ./bundle --signing-key ${{ vars.SIGNING_KEY }}
Publish it¶
| Where | Reference your users pass |
|---|---|
| Any HTTPS host | https://releases.example/demo-1.3.0.tar.zst |
| An OCI registry | oci://registry.example/demo/bundle:1.3.0 |
| A file they copy | ./demo-1.3.0.tar.zst |
HTTPS is a static file on any web server. TLS is not optional on the client side and a redirect out of it is refused, so a plain-HTTP mirror will not work. No credentials are sent, so a bundle behind authentication needs the registry route or an out-of-band download.
An OCI registry is the one that lets morzer release list enumerate your
versions, because a registry keeps a tag list and a URL does not. Publish the
archive as a single-layer artifact:
oras push registry.example/demo/bundle:1.3.0 \
--artifact-type application/vnd.morzer.release.bundle.v1 \
demo-1.3.0.tar.zst:application/vnd.morzer.release.bundle.v1.tar+zstd
Credentials come from your users' ambient Docker configuration, so anyone who
has run docker login for the registry your images come from can already
fetch the bundle that pins them.
Tell them the digest¶
morzer release verify ./my-product | tail -1
# sha256:bcca96e8020143562b3040e9c443f36ae57d8e2594c59b7f2499902416b211c5
Put it in your release notes. It is what turns
morzer update https://releases.example/demo-1.3.0.tar.zst
into
morzer update https://releases.example/demo-1.3.0.tar.zst --digest sha256:bcca96e8…
— the difference between "a release claiming to be 1.3.0" and "the release you published as 1.3.0".
Versioning¶
The version in your manifest is semantic and the manager compares it as such.
Never republish a version with different content¶
The manager refuses a version already installed with a different digest, rather
than overwriting it. There is no --force. The refusal is deliberate: the
release store is keyed by the version string and current and previous are
symlinks into it, so overwriting a directory would silently change what
rollback returns to.
What to do instead is publish a prerelease. Every build that is not a release gets its own version, so nothing ever collides:
morzer release build ./my-product --version-from-git
# 1.4.1-dev.7.g3be286c
--version-from-git runs git describe --tags --long --dirty and renders it as
<next-patch>-dev.<distance>.g<sha>. Three properties, each load-bearing:
- The patch is bumped.
1.4.0-dev.7sorts below1.4.0, so a development build named after the tag it follows would sort behind the release it comes after. Guessing the next patch is what makes it sort forward. - The sha is in there. Commit distance is not unique across branches: two
branches seven commits past
v1.4.0both producedev.7with different content, which is exactly the collision the never-republish rule catches. - The sha is a prerelease identifier, never build metadata. OCI tag grammar
excludes
+, so a version carrying metadata could never be a registry tag — andmetadata.versionrefuses one anyway, because build metadata is kept in the store's directory name and ignored by every comparison. Two builds differing only in metadata would be distinct releases that nothing can tell apart. Constraints such asupgrade_from: ">=1.0.0+build.7"are unaffected: a range is not an identity.
Exactly on a tag with a clean tree, the version is the tag verbatim — 1.4.0,
no suffix. That is the release build, and the only shape that produces a
non-prerelease version.
A dirty tree is refused. --allow-dirty stamps
1.4.1-dev.7.g3be286c.dirty, which sorts after the clean build at the same
commit — the correct reading, since it has content the commit does not.
Shallow checkouts fetch no tags
actions/checkout defaults to fetch-depth: 1 and fetches no tags, so
git describe fails. morzer fails loudly rather than defaulting to
something plausible: a silent 0.0.0 would produce a bundle that installs,
collides, and confuses. Set fetch-depth: 0, or pass --version.
--version is the real interface and accepts any valid semver, so calendar
versioning or a CI build number works — --version-from-git is the sugar on
top of it.
With neither flag, build uses the manifest's own version and stamps nothing.
The one version it refuses there is 0.0.0, the placeholder a scaffolded
bundle carries: it is legal at every other gate, so a forgotten flag in CI
ships a bundle that is clean everywhere and collides with the next forgetful
build.
Say what a release can and cannot do¶
Bump database_schema_max when a release can read a newer schema, and set
rollback_safe: false when its migrations are one-way. These are what let the
manager refuse an unsafe rollback instead of corrupting a database quietly.
rollback_safe: true now decides more than it used to, and this is the most
important thing on this page to re-read. It has always gated rollback. It also
decides whether a release may install itself on a customer's machine with nobody
present — so declaring it carelessly no longer costs a refused rollback, it
costs an unattended update that ends somewhere the previous release cannot read.
Unattended installs are opt-in from both sides, and yours is
database_schema_produces:
compatibility:
rollback_safe: true
database_schema_min: 12
database_schema_max: 14
database_schema_produces: 14 # what my migrations leave the database at
Declaring it says what your migrations do, which is what lets the manager run the rollback assessment before installing rather than after. Omitting it is a valid and conservative choice: your releases are still fetched, verified and staged on your customers' machines, and they are still told one is waiting — they just install it themselves. See unattended updates.