Skip to content

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:

installation.yaml
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.

.github/workflows/release.yaml
- 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.7 sorts below 1.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.0 both produce dev.7 with 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 — and metadata.version refuses 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 as upgrade_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.