Immutable releases with convco-version
How limen, our MCP server, goes from a commit to a release: convco-version works out the version and the changelog, a person decides when to publish, and nothing published can change afterwards.
convco-version is a GitHub Action around convco, a command-line tool written in Rust that reads Conventional Commits. The version and the changelog are convco's work: the action installs it, runs it and hands its answers to the workflow.
A draft on every push
limen keeps preparing and publishing in two workflows. A push to main runs
release-draft.yml, which only prepares the release. convco-version reads the Conventional
Commits since the last tag and answers three things: the next version, whether there is anything to
release, and the changelog. The workflow writes them into a single draft release. A draft creates no tag
and builds nothing, so it costs nothing to keep it up to date: a feat: after a
fix: turns the draft of 0.1.1 into 0.2.0, and the old one is deleted instead of announcing a
version that will never exist.
# release-draft.yml, the job that works out the version - uses: xoadev/convco-version@<sha> # vX.Y.Z id: version with: # Only what ends up in the binary or the image counts. paths: crates,Cargo.toml,Cargo.lock,rust-toolchain.toml,.cargo,etc/Dockerfile tag-prefix: v
paths is what keeps the version honest: a commit that only changes the documentation or
the CI does not make a new binary, so it does not make a new version either.
One repository, several version lines
limen's repository also holds its packs of scripts, under packs/, and each pack is released
on its own. packs-draft.yml runs the same action once per pack, each with its own folder and
its own tag prefix. Every pack gets its own tags, history and changelog, and limen's vX.Y.Z stays
apart.
# packs-draft.yml, once per pack - uses: xoadev/convco-version@<sha> # vX.Y.Z with: paths: packs/${{ matrix.pack }} tag-prefix: pack-${{ matrix.pack }}-v
The tags read pack-docker-vX.Y.Z, with no slash: a tag's name is also part of the URL its
files are downloaded from.
Publishing is a decision
Nothing is published on its own. Publishing is the other workflow, release.yml, which only
runs by hand (each pack's is packs.yml). It takes the draft's version and commit and then, in
this order:
- waits for the checks of that commit to be green;
- builds the static binaries for x86-64 and arm64, optimised and without caches;
- runs the end-to-end tests with those very binaries, against real SSH servers on Debian and on OpenWrt;
- builds the hub's image for both architectures and starts it on each;
- attests the binaries and the image, and attaches the binaries and their
SHA256SUMSto the draft; - and only then publishes the draft, which is what creates the tag.
It is one workflow run by hand, and not a tag that starts another workflow, for two reasons. What a workflow does with GitHub's own token starts no other workflow, so a tag it created would build nothing. And a release published from GitHub's releases page would be public before its files were there.
Why immutable
limen's releases are immutable: once published, neither their files nor their tag can change, and no
v* or pack-* tag can be moved or deleted. A version always means the same
bytes: the SHA256SUMS that the installer checks today will match next year, and a binary
can't be swapped under a version people already trust.
That is also why the files go on the draft first: a release that can't change has to be complete when it is published. And each binary and the image carry a build provenance attestation, so anyone can check where they came from:
gh attestation verify limen-<version>-linux-x86_64 --repo xoadev/limen
Tokens that read while others' code runs
The jobs that run someone else's code (convco, the compiler, the crates' build scripts, QEMU and
BuildKit) hold a token that can only read, and keep no credentials on disk. The jobs that write, the
draft, the release's files and the image, run nothing but gh, skopeo and
GitHub's attest on what the others handed over. Every action is pinned by commit SHA, the QEMU
and BuildKit images by digest, and convco-version checks convco's sha256 before running it.
What convco-version does, and what it doesn't
convco-version decides nothing: it hands over the version and the changelog, and the workflow chooses what to do with them. Here, a draft on every push and a release only when a person says so. The whole workflows are in limen's .github/workflows.