Versioning policy
This is the canonical versioning statement (REL-01, issue #445). It defines what a version number means for this project, what a release tag may look like, where the version comes from, and how release candidates are identified. The tag-format gate in .github/workflows/docker-publish.yml, the validation in .github/workflows/release.yml, and the versionpolicy package (backend/internal/versionpolicy) are all defined against this document; a change here is a change to those, and vice versa (see Document consistency).
This page is contributor- and maintainer-facing. Operators do not choose version numbers; what an operator needs — whether an upgrade requires anything of them — is the changelog and the upgrade-compatibility policy.
Semantic versioning
Mycorrhizal CRM versions are SemVer 2.0.0: MAJOR.MINOR.PATCH, tagged with a leading v. What the three components mean here:
| Component | Incremented when | Example |
|---|---|---|
| MAJOR | A breaking change to a covered surface ships. Post-1.0.0 this is the deprecation-and-removal endpoint, not a routine event. | 1.0.0 → 2.0.0 |
| MINOR | Backward-compatible functionality is added — a new endpoint, field, config variable, export element, CLI flag. | 1.2.0 → 1.3.0 |
| PATCH | Backward-compatible bug fixes only; no new surface. | 1.2.3 → 1.2.4 |
What counts as breaking is not decided here. It is decided once, in writing, by the breaking-change policy (MAINT-02, issue #491) — covered surfaces, the breaking/additive line, and the fact that a breaking change is 2.0.0, never a parallel /api/v2. The removal window for anything that policy classifies as breaking is the deprecation policy (MAINT-01, issue #490). This page points at both rather than restating them.
The pre-1.0 rule, as it actually is
0.x makes no backward-compatibility promise. Breaking API and contract changes still happen in 0.x; this is pre-1.0 software and CLAUDE.md says so plainly. The roughly-one-release-a-day cadence between v0.2.0 (2026-08-04) and v0.6.0 (2026-08-22) was legitimate under this rule, not sloppy.
Two things are not waived pre-1.0:
- Data preservation. Since
v0.2.0-alpha-candidatethere is real production data. A migration that loses data is breaking regardless of version (CLAUDE.md: “breaking data is a different, higher bar”). This is independent of the SemVer component being bumped. - The supported-upgrade floor. In-place upgrade is supported from
v0.6.0and later, version-skipping included — see upgrade compatibility (issue #529).
The /api/v1 compatibility promise — no removals, renames, or narrowing within a major line — begins at 1.0.0. See the breaking-change policy for its exact wording.
Release-tag format
A release tag is:
^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$
That is: a leading v, three dot-separated non-negative integers, and an optional -rc.N pre-release suffix (N a positive integer). Nothing else — no -alpha, no -beta, no +build metadata, no date suffix, no .0 fourth component.
- The exact expression above is the one authoritative copy. It is embedded verbatim in
backend/internal/versionpolicy/tagpattern.go(TagPattern), in therelease.ymlversion check, and in thedocker-publish.ymltag gate; a test fails if the three drift apart. - A tag that does not match fails CI.
docker-publish.ymlpublishes onpush: tags: ['v*']; its first job (validate-tag) rejects any pushed tag that violates the pattern, and the release/image/APK jobs depend on it, so a malformed tag produces no GitHub Release, no images, and no APK. - The historical
v0.2.0-alpha-candidatetag predates this policy. It is never re-pushed, so it needs no exception in the gate; it simply would not be accepted today.
Release candidates
0.9.x is a release-candidate series by design (the RC milestone). RC tags are vMAJOR.MINOR.PATCH-rc.N, numbered from 1:
v1.0.0-rc.1
v1.0.0-rc.2
...
The RC series leading to 1.0.0 is tagged v1.0.0-rc.N. The v0.9.x milestone is the planning bucket for that work; the tags it produces are the v1.0.0-rc.N series, not a v0.9.x release line.
Why v1.0.0-rc.N and not v0.9.0, v0.9.1, …:
- It is standard SemVer. A hyphenated identifier is a pre-release by definition, so “mark the GitHub Release as a pre-release” and “keep it off the Obtainium auto-update channel” become mechanical rather than a naming convention someone has to remember (RC-02, issue #446).
- It sorts correctly:
v1.0.0-rc.1 < v1.0.0-rc.2 < v1.0.0. Av0.9.xscheme sorts the RCs as ordinary releases belowv1.0.0with no pre-release signal.
RC publication mechanics — the pre-release flag, keeping RCs off the auto-update channels, the release/vX.Y.0 branch policy, the RC-fix merge criterion and iteration loop, and the digest-verified promotion path from an RC to the final tag — are RC-02 (issue #446), written up in release-candidate-process.md. release.yml cuts an RC when version carries an -rc.N suffix (with ref: release/vX.Y.0); promote-rc.yml promotes it.
Where the version comes from
The git tag is the single source of the version. Everything else is derived from it at build time; there are no hand-edited version constants on the release path.
| Derivation | Mechanism |
|---|---|
Backend buildinfo.Version (/health, the About dialog) | -ldflags -X mycorrhizal/buildinfo.Version=…, set from the tag in Dockerfile and backend/Makefile; see backend/buildinfo/buildinfo.go |
Docker image tags (…:1.2.3, …:1.2) | docker/metadata-action type=semver, value = the release tag, in .github/workflows/docker-publish.yml |
Android versionName | docker-publish.yml “Compute release version” strips the leading v and passes -PMYCORRHIZAL_VERSION_NAME to Gradle |
Android versionCode | see below |
Non-authoritative placeholders
Two version-shaped literals exist in the tree and are not sources of truth. They are placeholders for non-release builds and are overridden on the release path:
frontend/package.json"version": "0.1.0"— the frontend’s shipped version isVITE_APP_VERSION, injected at image build fromAPP_VERSION(the tag). Thepackage.jsonfield is inert.MycorrhizalAndroidApplicationPlugin.ktversionCode = … ?: 1/versionName = … ?: "0.1.0"— the fallbacks for a local or manual (android-apk-build.yml) build. A release build always passes the real values.
versionpolicy.TestKnownVersionPlaceholdersUnchanged pins both. If either is turned into a real version number, that test fails — the prompt to update this section, not to grow a second version source.
Android versionCode
Android refuses an in-place upgrade unless the new APK’s versionCode is strictly greater than the installed one, so the rule must be monotonic release-over-release:
versionCode = 1000 + GITHUB_RUN_NUMBER — the run number of the docker-publish.yml run that builds the release APK.
GITHUB_RUN_NUMBER increases by one on every run of that workflow and never resets, so it is strictly increasing across releases by construction. The +1000 offset keeps every release above 1, the static versionCode that every non-release build (local, android-apk-build.yml) carries. The rule deliberately does not parse MAJOR.MINOR.PATCH out of the tag: v1.0.0 and v1.0.0-rc.1 would otherwise collide on one versionCode.
The runtime assertion that release N+1’s APK actually installs over release N’s is ANDROID / issue #527 (the APK release gate), not this page.
Version numbers and the supported floors
Two floors are expressed as versions and therefore depend on this policy:
- Supported-upgrade floor —
v0.6.0. In-place upgrade is supported from here; below it the server refuses to migrate. Canonical statement: upgrade compatibility (issue #529). The floor moves only at a MAJOR bump, which is itself a breaking change. - Client/server compatibility floor. The Android app declares the oldest server it will talk to —
v0.6.0, the same version as the supported-upgrade floor above, so the client’s server baseline and the backend migration floor cannot drift. A server may declare the oldest client it accepts viaMIN_CLIENT_VERSION; no such client floor has ever been raised, so against every released server at or abovev0.6.0every released client still works. Canonical statement: client/server compatibility policy (ANDROID-01, issue #478).
The floors are not a support window. They say which old versions can still upgrade or still connect; they do not say how long a released version receives fixes. That is a separate promise, defined at 1.0.0 (issue #956): each 1.x release is supported for 12 months from the next minor release, and a release outside that window is end-of-life with no backports. The policy and the annual EOL review live in the security stewardship cadence; until 1.0.0 only the latest tag is supported (SECURITY.md).
Stray and unconstrained tags
The repository inherited a v1.7.0 tag from upstream meerkat-crm in some local checkouts (issue #545). origin never carried it, and no automation on main resolves “the highest tag” or runs git describe, so it never had a code path to do harm — #545 closed with that finding and a forward rule, restated here as policy:
- Any tag query added in future must be constrained. A
git describe, a changelog range, a “previous release” lookup for aversionCodediff — each must pass--match 'v[0-9]*'(or tighter) and a comment saying why. An unconstrained “latest tag” is a defect. - A
v1.xtag means MAJOR = 1, which only happens at the1.0.0stability contract. An accidentalv1.somethingis caught at review, and thevalidate-taggate still requires it to be well-formed — but the gate cannot know MAJOR was unintentional, so the human check is the real guard here.
How a release is cut
release.yml (workflow_dispatch, one input: the version) does the whole cut: register the schema fixture, commit it to main, push the tag, and let docker-publish.yml build and sign everything. The full description, and the verification commands for the published artifacts, are in Verifying release artifacts.
Document consistency
versionpolicy.TagPattern(backend/internal/versionpolicy/tagpattern.go) is the one authoritative tag-format expression.versionpolicy.TestWorkflowsAndDocUseCanonicalPatternasserts the identical string appears in this document,release.yml, anddocker-publish.yml;versionpolicy.TestGatePatternAcceptsAndRejectscompiles the copy embedded indocker-publish.ymland checks it accepts and rejects the right tags.versionpolicy.TestKnownVersionPlaceholdersUnchangedpins the two non-authoritative version placeholders named above.- These run in the normal backend
go test ./..., so the published policy cannot drift from the gate or the code without a red build.