Versioning¶
Two numbers, decided in two different ways. The release version is a
judgement and lives in Cargo.toml; everything else about a build is derived
from git and never typed by anyone.
Before you commit¶
Nothing. Ordinary commits do not touch the version. Push as often as you like; CI builds every push and publishes nothing, because the tag it would create already exists.
Bump Cargo.toml only when you intend to release, which is the one
decision a person has to make.
Pushing that bump to main is what makes a release: CI tags v{version},
builds both architectures, and attaches the packages. Pushing anything else
builds and tests but publishes nothing.
Which number to move¶
Features go in the second, fixes in the third, and packaging-only changes
never touch the version at all — they land in the Debian revision, which is
derived rather than typed. One adjustment applies while the major is still
0, which is today.
Ask the questions in this order. The first one that answers yes decides it, because compatibility outranks how the change feels:
- Will a saved configuration stop loading, or start meaning something different? → breaking (see the note below for which digit that is today)
- Can an operator do something they could not do before? → minor
- Otherwise → patch
The order matters because the two ways of asking can disagree. A change you would naturally call a fix — tightening a field that was wrongly permissive — still refuses somebody's saved document, and the version has to say so. When "feature or fix?" and "does it still load?" point at different digits, the compatibility answer wins.
| Change | Move |
|---|---|
| A field is removed or renamed; a document that used to load is refused; an existing field changes meaning | breaking |
| A default changes such that an unattended box behaves differently after upgrade | breaking |
| A new field, endpoint, preset, or capability | minor |
| An existing field accepts something it used to reject | minor |
| A fix that changes no shape: wrong output, a crash, a bad blend ramp | patch |
| Docs, tests, CI, refactoring with no outward effect | nothing |
| Packaging: unit file, dependencies, install scripts, cargo-deb settings | nothing — see the revision |
The second row catches the case that is easiest to under-call. Moving the default API port from 9080 to 9088 changed no schema and refused no document, so it looks like a patch — but an appliance that nobody logs into comes back after the upgrade on a port its operator is not expecting, which is indistinguishable from the box being dead. Behavior an unattended machine depends on is part of the contract, the same as a field name.
Suede is an appliance daemon, not a library, so nobody resolves against its version as a dependency. What the number is for is telling an operator whether an upgrade can break the box they already configured — which is why the table is about documents and behavior, not about how much code moved.
While the major is 0¶
The breaking slot is the minor, not the major
Suede is still 0.x. By semver convention nothing below 1.0.0 promises
stability, so a breaking change goes 0.1.x → 0.2.0, and patch carries
everything else. Read "breaking" in the table above as minor for now,
and "minor" as patch.
At 1.0.0 the three columns line up with their names and the table reads
literally. That is the point to reach deliberately — it is a promise that
saved configurations survive every 1.x upgrade, not a milestone to drift
into.
The fourth number, and where it went¶
In a four-part .NET version the last field is for build and packaging
revisions that do not change the contents. Debian has that field too, and it
is the revision after the hyphen — 0.1.0-1 — which is precisely why the
commit height lives there and not in the patch digit.
The difference is that you never type it. Fixing a unit file or a dependency is a commit, so the height advances and the revision moves on its own:
Same source version, next packaging build, and it upgrades cleanly. Nothing to remember.
Repackaging a release with no code change
A build sitting exactly on its tag takes a plain -1, so a packaging fix
to an already-published release is the one case the derivation does not
cover: you would produce 0.1.0-1 a second time with different contents.
Tag it again (v0.1.1) if anything reached a user, or pass
--deb-revision 2 by hand if it did not. Re-issuing the same version with
different contents is the thing to avoid, whichever route you take.
Breaking changes move two numbers¶
The package version is for a person; SCHEMA_VERSION in
src/model/desired.rs
is the machine's version of the same statement. A document stamped with a
schema newer than the daemon understands is refused outright rather than
half-read.
So a breaking configuration change bumps both, in the same commit. Moving
the release version alone leaves the daemon quietly accepting a document it
should reject; moving SCHEMA_VERSION alone means an operator gets a refusal
from a version number that promised compatibility. Either way round, the two
answers disagree, and that is a bug rather than an oversight.
What a build calls itself¶
git describe is the source, so every build can be traced to a commit:
The release it is meant to be, then the last tag, how far past it, and the
exact commit. A working tree with uncommitted changes says so — -dirty —
which matters most on the machine where the changes are being made.
A release build has nothing to add, so it does not pad the line out:
The parenthesis appears only when it says something the version does not. Its absence is therefore information in itself: this is a release, not a build that happens to be near one.
GET /api/v1/system reports the same thing as buildId beside
suedeVersion, so it can be read from a machine you are not sitting at.
This exists because 0.1.0 alone cannot answer the question anyone actually
has. Installing a fix and seeing no change, you cannot tell whether the
package failed to install, the daemon failed to restart, or the fix was never
in that build. That happened, and it cost an afternoon.
Building without git
A source tarball has no history, so the build script falls back to
unknown and --version prints the bare version. Set SUEDE_BUILD_ID
to state the answer instead — CI does exactly this, because cross
builds inside a container that has neither the history nor git itself.
What a package calls itself¶
The Debian revision carries the same information, in a form dpkg can order:
| Where HEAD is | Version | Meaning |
|---|---|---|
| Exactly on the tag | 0.1.0-1 |
The release |
| 12 commits past it | 0.1.0-1+12.g81226ee |
A development build |
+ sorts above a plain 1, so a development build supersedes the release it
follows, and 0.2.0-1 still supersedes both. The height is what makes
successive development builds ordered.
The suffix does order, and that is not an accident of formatting. dpkg
compares a version by splitting it into alternating runs of non-digits and
digits, comparing the digit runs numerically. So +9. and +12. are
compared as 9 against 12, not as the strings 9 and 12:
0.1.0-1+9.gaaaaaaa < 0.1.0-1+12.gbbbbbbb
0.1.0-1+12.gbbbbbbb < 0.1.0-1+100.gccccccc
0.1.0-1 < 0.1.0-1+1.gaaaaaaa the release, then builds after it
0.1.0-1+99.gzzzzzzz < 0.2.0-1 and the next release beats them all
Why not the commit hash alone
Stamping 1+g81226ee was tried first and is subtly broken: dpkg compares
those suffixes lexically, so ordering follows the alphabet rather than
time. Measured on real builds:
Roughly half of upgrades between development builds would be refused as downgrades. The height fixes it because it only ever increases.
Why the height goes after the hyphen, not in the third digit¶
The obvious alternative is to let commit height be the third digit —
0.1.12 for twelve commits past 0.1 — which is what Nerdbank.GitVersioning
does. It does not fit here, for one reason of meaning and one of mechanism.
The third digit is patch, not a build counter. It is a claim that a
release happened. Spending it on height means 0.1.12 asserts twelve patch
releases that nobody made, and — worse — leaves no digit to say this is a
bug-fix release of 0.1.0, because the position that would have said it is
already occupied by an accident of how many commits were on the branch.
The .NET pattern works because assembly versions are
Major.Minor.Build.Revision: four fields, no semver contract, and the third
is literally named Build. Cargo has three fields and a contract about what
they mean.
Debian already has the field for this. A package version is
upstream_version-debian_revision, and the revision means which build of
that upstream version this is. That is exactly what commit height is, so it
goes there:
Both parts then say something true at once: the release this is working towards, and how far past it this particular build sits. Neither has to lie about the other.
The mechanical reason is smaller but decides it anyway — see below.
Building one by hand¶
The same scheme, for a build you want to put on a machine:
cargo build --release
cargo deb --no-build --deb-revision "1+$(git rev-list --count "$(git describe --tags --abbrev=0)..HEAD").g$(git rev-parse --short HEAD)"
Then dpkg-query -W suede on the appliance tells you exactly which commit is
installed, and apt-get install ./suede_*.deb upgrades rather than refusing.
Why not commit height in Cargo.toml¶
This is the one place the .NET pattern does not transfer. Nerdbank.GitVersioning
rewrites the version at build time from version.json plus commit height, and
the equivalent here would mean rewriting Cargo.toml in CI: Cargo requires a
literal version string and will not compute one.
That churns Cargo.lock on every build and makes a local cargo build
disagree with CI about what it just produced. The Rust-idiomatic split is the
one above — the manifest states intent, the build records identity — and it
gets you the same traceability without a generated manifest.