Contributing
Releases and stability
Before you read: Contribution workflow (the version gate) and Publishing the documentation (what a release regenerates).
This page is about the other half of a release: what the version in Cargo.toml promises,
what happens when a v* tag is pushed, and what the engine guarantees will not break without a
major version. Publishing the documentation already covers the docs side of
a release (what regenerates, what CI checks); this page covers the version number itself and the
CLI/manifest contract it backs.
The version gate#
scripts/version_gate.py (CI job version, fetch-depth: 0 because it needs every tag) allows
the workspace version in the root Cargo.toml to be exactly one of two things:
- Equal to the newest tag this commit contains. Ordinary work between releases. Two builds
with the same version number are told apart by
delonix --version, which prints the distance from that tag:commit: <hash> (+N commits since vX.Y.Z). - Greater than the newest contained tag, only in the release commit — and then
docs/releases/v<version>.mdmust exist, because the release workflow publishes it as the GitHub Release body.
Everything else fails, each with a reason tied to a real failure mode:
| What the gate sees | Why it fails |
|---|---|
| A newer tag exists that this commit does not contain | The branch started before that release; merging it as-is undoes what the release already shipped. Merge origin/main in first. |
Cargo.toml is lower than the newest contained tag |
An older version number would be published over a newer one. |
Cargo.toml is higher than the newest contained tag, with no docs/ |
A bump with no matching tag — sends the delonix-cri download (which is resolved from the running binary's own version, see vmimage.rs) to a release that will never exist. |
The version is not decoration: besides the delonix-cri download, it is the Docker API's
ServerVersion, the delonix_version recorded in every resource backup, and what the release
workflow (below) checks the built binary against before publishing anything.
Do not bump the version in a feature PR, and do not use a -dev suffix — the gate's first rule
already covers ordinary work, and a -dev suffix would point the CRI download at a release that
does not exist.
What publishing a release does#
Pushing the tag publishes nothing. Since 2026-09-23 the release workflow is
workflow_dispatch-only: the tag and the release are two separate decisions, so a tag that
reaches the remote by accident (git push --tags, push.followTags=true) can no longer put a
binary in front of the world. A tag with no release is a normal intermediate state here.
git tag -a v4.4.0 -m "v4.4.0" && git push origin v4.4.0 # cuts the version
gh workflow run release.yml -f tag=v4.4.0 # publishes it
A guard job runs first and costs seconds: it refuses a tag that is not shaped vX.Y.Z, one
that does not exist on the remote, and one that already has a release — the input picks the commit
to build and names the release, so an unchecked tag: main would have published a release called
"main". Only then, in one job:
- Builds
delonix,delonix-cri,delonix-mcpanddelonix-mgmttwice — once generic x86-64, once with-C target-cpu=x86-64-v3(AVX2/BMI2/FMA) — onubuntu-22.04specifically, so the glibc baseline (2.35) stays compatible with RHEL 9 and Debian 12, not just the newest Ubuntu.scripts/install.shpicks the-v3build automatically when the host CPU supports it. A separatebuild-arm64job builds the same four binaries natively on an aarch64 runner (one per component, no-v3variant), and they are published as<name>-aarch64-linuxunder the sameSHA256SUMS.install.shinstalls them on an aarch64 host (#447). The job only runs at release time, so its first execution was the v4.2.0 release itself; CI runs the test suite natively on arm64 in thetest (arm64)job on every PR. - Regenerates the user site against this exact release build and fails if
docs/differs. This gate exists because it once did not: a site gap shipped live in v0.48.0, hiding a new command for hours while a parallel CI job was already red about it — the two workflows just were not looking at each other. Runningdocs/gen.pyhere, before publishing, closes that. - Builds a checksummed
SHA256SUMS, an SPDX 2.3 SBOM (scripts/sbom.py, fromCargo.lock, itself hashed intoSHA256SUMS), and — when theMINISIGN_SECRET_KEYsecret is configured — a minisign signature overSHA256SUMS.SHA256SUMSalone only proves transfer integrity (it comes from the same URL as the binary); the signature proves the release came from this project, sinceinstall.shcarries the public key embedded and refuses to install an unsigned release without--insecure-skip-signature. Failing to sign when a key is configured is a hard error — it would break every installer's signature check at once. - Verifies its own binary reports the tag's version (
delonix --version | grep <tag>) before publishing anything — the same class of checkversion_gate.pyruns earlier, against the artefact that is actually about to ship. - Attaches SLSA build provenance (
actions/attest-build-provenance, GitHub's own action, kept separate from minisign on purpose: provenance proves where and from which commit something was built, to someone who does not trust the project up front; minisign proves the release is this project's, to someone who already trusts its embedded public key). - Publishes the GitHub Release, with
docs/releases/<tag>.mdas its notes when that file exists, or--generate-notesotherwise. - Checks out
mainand regeneratesdocs/RELEASES.md(scripts/gen-releases.sh) and the handbook's generated facts (scripts/dev_docs.py) and, separately, the handbook site (scripts/dev_docs_site.py) — committing whichever changed,[skip ci], so the docs never trail a release by more than this one commit. A generator failure here is a loud warning, not a failed release: the release itself does not depend on it.
The narrative half of the handbook — the prose on these pages — is not regenerated by CI. After a release is published, a maintainer-run review reads what changed since the previous tag (commits, release notes) and updates only the pages that change affects, exactly as described in Publishing the documentation § What happens at release time.
What is stable, and where that promise lives#
docs/cli-stability.md is the actual contract, in the same repository, read by delonix explain
and generated pages alike — this section only orients you to it, since duplicating its content
here would give it a second copy to fall out of step with the code. It has applied since v0.42.3
and, as of v1.0.0, reads as the project's real semver promise rather than a within-0.x note.
Stable — does not break without a major:
- The container/image lifecycle verbs (
container run,ps,stop,exec, …) and image verbs, with Docker/Podman's own names and argument order, and the specific short/long flagsdocs/cli-stability.mdlists forrun/exec. - Exit codes (
0success,4not found,5conflict,69missing host capability,124timeout, …) and theDX-CDNNdictionary number each failure carries (ADR-0043) — the number identifies which failure and never changes meaning or gets reused;delonix explain DX-4501looks one up. -o jsonon every listing command: fields can be added, never removed or retyped (ADR-0005).- The manifest schema for Kinds with a typed spec (
Container,Pod,Volume,Network, and the othersdelonix manifest schemalists): a field is never removed, retyped or repurposed; a new field is always optional with a default that preserves old behaviour; a renamed field keeps the old spelling as an alias;apiVersion: delonix.io/v1keeps loading even after the per-domain groups (compute.delonix.io/v1alpha1, …) became canonical. This is the promise that matters most in practice — it protects what people put in git and review in a PR, not just what they type at a prompt.
Not stable — can change in any version: serve cri/serve api/serve docker-api (the local
management API in particular has no published contract and is explicitly not something to
automate against — see The crates § delonix-mgmt and ADR-0040/0041);
the cluster/vm/pod/workload/net imperative surfaces (their manifest schema, where one
exists, is covered above — only the verbs and flags around it are not); compose; backup;
mcp; system/dashboard/completion/init/man/config/explain; the on-disk state format
under $DELONIX_ROOT; and stack history/stack rollback (ADR-0019 — nothing reads that
history to decide what exists, so losing it changes nothing the reconciler does).
How a breaking change is made, when it has to happen#
The precedent, already applied more than once (the v0.30.0 CLI reorganisation, the v2.0.0
image list→image ls reversal): a clean cut, no compatibility alias. The old spelling fails
with unrecognized subcommand, loudly, in every version from the break onward — never a silent
alias that quietly changes behaviour later. docs/cli-stability.md § Como uma quebra é feita
records a real lesson from doing this: a rename can leave an internal caller behind (the CRI
server itself kept invoking a removed delonix netns attach for months after the v0.30.0
reorganisation, breaking rootless pod creation) — grepping the whole workspace for the old
spelling, not just the docs and the tests, is part of making the cut.
A breaking change to something this page or docs/cli-stability.md marks stable needs an ADR
first (Contribution workflow § When to write an ADR),
because it moves a structural boundary by definition — the same reasoning that applies to a new
backend or a new privilege boundary applies here too.
Next: Publishing the documentation — how the site and this handbook are generated, gated and published, and what your PR must regenerate.