Versioning
How platform constructs and internal libraries are versioned — VERSIONING.md
Versioning the platform's own things
Three kinds of internal artefact, one scheme each, and the differences are not cosmetic. Getting them confused is how a bump that should be invisible breaks a project, and how a breaking change ships as a patch.
| artefact | where the version lives | scheme |
|---|---|---|
| Platform constructs — component types, resource types, traits, workflows, project types | openchoreo.dev/version annotation |
semver over the contract |
Internal libraries — platform/gems/*, platform/pylib/* |
version.rb / pyproject.toml |
ordinary semver over the API |
| Application images | the image tag | not semver, and deliberately so |
1. Platform constructs
What the number describes
The contract, not the implementation. A construct's contract is exactly the part a consumer can see and depend on:
- the
parametersschema — what a developer may set, and what is required; - the
environmentConfigsschema — what a platform engineer may tune per environment; - the outputs — for a resource type, the keys other components bind to
(
host,port,url, …). This is the most load-bearing part and the easiest to forget:inkwell-enrichsurvived MinIO being replaced by Garage without a single change, because the objectstore contract kept promisingS3_ENDPOINTandS3_BUCKET; - the
validations— a new rule can reject a manifest that used to apply; - the observable shape of what it renders, where a consumer could reasonably depend on it (a Service name, a port, a mounted path).
Everything else is implementation and does not move the number. Switching a resource type from MinIO to Garage was a patch by this rule, because nothing in the contract changed — and that is the right answer, even though it replaced the entire backing service.
When to bump what
MAJOR — a consumer must change something, or will silently get different behaviour:
- removing or renaming a parameter, an environment config, or an output;
- making an optional parameter required;
- narrowing a type or pattern so a previously valid value is now rejected;
- adding a
validationthat existing manifests fail; - changing a default in a way that changes what gets deployed.
MINOR — new capability, every existing consumer unaffected:
- adding an optional parameter with a default;
- adding an output;
- adding an environment config;
- rendering an additional object that nothing was depending on the absence of.
PATCH — no contract change at all:
- a fixed CEL expression, a corrected probe, a bumped image tag inside the template, a comment;
- replacing the implementation entirely while keeping every output identical.
The rule that makes it usable
Bumping the version means bumping the stamp. A construct declares its version and stamps the same value into everything it renders:
metadata:
annotations:
openchoreo.dev/version: "1.1.0"
...
template:
metadata:
annotations:
openchoreo.dev/rendered-from-version: "1.1.0"
openchoreo.dev/rendered-from-type: postgres
The stamp is what lets a live resource say which version built it, and therefore
what lets the console answer "is this consumer on the current platform release"
instead of unknown. platform/upgrade-watch checks the two agree and reports a
construct that declares one version and stamps another — a live resource claiming
to have been built by a version that never existed is worse than no answer.
resource-types/postgres.yaml is the worked example.
Starting at 1.0.0, not 0.1.0
Every construct here already has consumers in production. 0.x advertises "the
contract may change without notice", which would be a lie about a resource type
holding somebody's database. A construct that genuinely is experimental should say
so with openchoreo.dev/description and a 0.x version, and none currently is.
Pinning a version
Consumers pin, and the pin can move. This used to say the opposite — that
consumers could not express a version, and that the scheme was a policy rather
than a mechanism. It is a mechanism now, and the difference is the whole point:
before it, editing a construct cut a new ComponentRelease for every consumer
within seconds, staging was upgraded involuntarily on the next push, production
was upgraded silently on the next promotion, and nobody had chosen anything.
spec:
componentType:
kind: ClusterComponentType
name: deployment/ruby-service
version: "2" # omit for the old behaviour: whatever it says today
traits:
- {kind: ClusterTrait, name: otel-instrumentation, instanceName: otel, version: "1"}
version is accepted on the four places a consumer NAMES a construct:
componentType, each entry in traits, workflow, and spec.type on a
Project or a Resource. Empty means floating, which is what every
manifest written before this does, so nothing changed for anyone who has not
opted in.
A pin is a range, and its length is the whole policy
2 accepts any 2.x.y, 2.0 accepts any 2.0.x, 2.0.1 accepts only itself.
The newest satisfying version wins. That single rule is what makes MAJOR, MINOR
and PATCH mean different things to a consumer rather than only to whoever cut
them:
| producer does | consumer pinned 2 |
2.0 |
2.0.1 |
nothing |
|---|---|---|---|---|
| patch 2.0.1 → 2.0.2 | yes | yes | no | yes |
| minor 2.0.2 → 2.1.0 | yes | no | no | yes |
| major 2.x → 3.0.0 | no | no | no | yes |
Pin the major. That is the granularity at which "you must change something" is true, it is the only one the support window below can mean anything about, and it is the one that lets a patch stay invisible — which is the entire reason patch exists as a category.
An exact three-part pin is a freeze, and a legitimate one; just know that it is what you asked for. Pinning nothing is also a real choice and is what most manifests here still do.
This was broken before it was built. Resolution used to be label EQUALITY,
so a pin was always exact whatever it looked like. Bumping ruby-service from
1.1.0 to 1.1.1 — a corrected probe, a fixed CEL expression, by definition no
contract change — rewrote the version label in place and every consumer pinned
to 1.1.0 then resolved to zero matches and failed with "not published, or
has been retired". A patch could not be shipped to anyone who had opted in to
being careful. Nobody hit it only because there were two pins on the platform.
And the CRD's own pattern already accepted "2" and "2.0", which the resolver
could never satisfy: the schema described this design before the code did.
How it resolves. By label, not by object name: the platform publishes each
construct carrying openchoreo.dev/name and openchoreo.dev/version. A pinned
reference lists by the NAME label and filters in the controller, because an API
server can match a label for equality and cannot match a prefix — the candidate
set is every published version of one construct, which is two here and never
more than a handful.
Zero matches is an error, and it now names what is published:
no ClusterComponentType labelled openchoreo.dev/name=ruby-service with a version
satisfying "9" (published: ruby-service-2=2.0.0, ruby-service=1.1.0). The version
is not published, or has been retired; `kubectl get ClusterComponentType -l
openchoreo.dev/name=ruby-service` lists the versions that exist
Two objects at the same full version is still an error, and still refuses rather than picking one — a consumer would otherwise be rendered from whichever object sorted earlier, silently, and differently on a different day. Two objects at different versions both satisfying a range is not that case; it is what a range is for, and the newest wins.
A version carrying a pre-release or build suffix (2.0.0-rc1) is matched
exactly and never ranged over, because it is a different thing from 2.0.0
and nobody pinning it wants it quietly satisfied by something else.
Verified live against bakery-web: "1", "1.1" and "1.1.0" all resolved to
ruby-service 1.1.0; "2" resolved to ruby-service-2 and froze a spec whose
sizeClass enum is [small, medium, large], which is the 2.0.0 contract with
xlarge removed — so the range picked the object and not merely a string.
"1.0" was refused, because 1.1.0 does not satisfy it.
This rule is now implemented twice, in two languages, and that is a
liability. satisfies() in versioned.go decides what actually renders;
satisfies() in the console's platform_catalog.py decides who a version's
consumers are. The second was written later and got it wrong in the ordinary
direction — it compared the pin to the published version for equality, so the
trait page said "Used by 0 — nothing on this platform uses it" about a trait
chess-opening had pinned that morning. Note which way the error fell: an exact
three-part pin, the form we discourage, was counted correctly, and every
recommended pin was counted as nothing at all. Both copies now carry the same
case table as a test, including the one a naive string prefix fails ("2" must
not accept 20.0.0), because the divergence surfaces as a count nobody
double-checks rather than as an error.
A third comparison lives in upgrade-watch (versions.py), and it is
deliberately not the same rule: it orders versions and computes the support
window below, and it reads schemes this one never sees (RELEASE.2024-…,
v1.36.1-k3s1, pg17) because it compares upstream image tags rather than our
own contracts.
Which means a construct that annotates a version and does not LABEL it
declares a version nobody can pin. Until 2026-09-06 that was four
ClusterWorkflows, the default ClusterProjectType and the external-api
ComponentType — so workflow: and spec.type, two of the four places the CRD
accepts a version, could not resolve one at all. They are labelled now, and both
were exercised for the first time: a Project pinned to project type 1.0.0
reconciled, and a Component pinned to workflow fastapi-builder 1.0.0 cut a
release and managed its binding.
Eleven constructs remain unlabelled and that is correct — they are upstream's,
installed by the chart (service, worker, web-application, scheduled-task,
dockerfile-builder, the three buildpack builders, observability-alert-rule,
nats, valkey). They declare no version at all, so they are the gap state
upgrade-watch already reports and not an omission here.
A pin to an unpublished version is invisible outside the controller log. The
refusal is exactly right — no ClusterProjectType labelled openchoreo.dev/name=default,openchoreo.dev/version=9.9.9 … kubectl get ClusterProjectType -l openchoreo.dev/name=default lists the versions that exist
— but it is returned as a reconciler error, so status.conditions keeps the
LAST SUCCESSFUL reconcile. kubectl get project and the console both say
Ready=True … ProjectRelease chess-7449b895f4 in place while the controller
loops on the refusal several times a second. Verified by doing it. A consumer
that mistypes a version gets a healthy-looking project that has silently stopped
tracking its own manifest; the same shape as every other "the console reported
this as nothing" finding, and the reason a refusal has to reach a condition.
Where a construct reference is dereferenced, and where it is only compared. Audited before touching object names, because the answer decides whether renaming an object is safe:
| reference | mechanism | breaks if the object is renamed |
|---|---|---|
Component.spec.componentType |
ResolveVersionedName |
only the floating path |
Component.spec.traits[], ComponentType.spec.traits[] |
ResolveVersionedName |
only the floating path |
Component.spec.workflow |
ResolveVersionedName |
only the floating path |
Project.spec.type, Resource.spec.type |
ResolveVersionedName |
only the floating path |
ComponentType.spec.allowedTraits |
string compare, never dereferenced | no |
ComponentType.spec.allowedWorkflows |
string compare, never dereferenced | no |
the API's /{plural}/{name} CRUD |
the object's own name | yes — console construct URLs |
Both allow-lists build a kind:name set out of the MANIFEST and compare the
manifest's other half against it (internal/validation/component/component_traits.go
ValidateAllowedTraits, and the allowedSet in internal/controller/component/ controller.go). Neither ever fetches the object, so neither constrains what an
object may be called. The admission webhooks check the same lists for empties,
duplicates and overlap — also without dereferencing.
So the only thing an object rename can break is the FLOATING path, where an
empty version falls back to fetching metadata.name directly.
That audit was incomplete when it was first written, and the gap is worth
recording because it was invisible: ResolveWorkflow in
internal/controller/reference.go dereferenced a Component's LOGICAL workflow
name with a plain Get, and so did two paths in the API server —
autobuild's schema read, which runs on every push, and the workflowrun service's
existence check. Two more did the same for traits and component types in the
API's schema preview. Renaming a ClusterWorkflow with those in place would have
broken every build on the platform, and it would have failed as "workflow not
found" rather than as anything about versions. All six now go through the
resolver, which is why the fork is two images and not one.
Object names carry the major, including the first
OBJECT LABELS
ruby-service-v1 openchoreo.dev/name: ruby-service
openchoreo.dev/version: 1.1.0
openchoreo.dev/default: "true"
ruby-service-v2 openchoreo.dev/name: ruby-service
openchoreo.dev/version: 2.0.0
ruby-service and ruby-service-2 were a scheme in which the FIRST version's
name was an accident of being published first and every later one was a suffix
somebody typed. The two could drift apart the moment anyone passed the wrong
one, and the object list did not say what it held. Every object now says which
major it is, and the suffix is DERIVED from CONTRACT_VERSION rather than
passed, because a suffix that can disagree with the version it encodes
eventually will.
The logical name is the label and never metadata.name. A consumer writes
name: ruby-service and, if it wants to choose, a version beside it — it names
neither object.
MINOR AND PATCH DO NOT GET A NEW OBJECT. They move the existing -v<major>
object forward in place, which is exactly what makes them invisible to a
consumer pinned to the major. Only a MAJOR is a new object, because only a major
is a thing a consumer has to decide about.
Which version an unpinned consumer gets
openchoreo.dev/default: "true", on exactly one object per logical name.
This became necessary the moment the major went into every name. Before it,
floating meant "fetch the object called ruby-service", and which version that
was depended on which one happened to be published first — an accident nobody
chose. ruby-service-v1 and ruby-service-v2 are symmetrical, so something has
to say, and it should be a label a platform engineer moves deliberately.
Resolution falls back to the bare object name when no object carries the label, and that fallback is the migration rather than a hedge: every construct published before this is reachable at its logical name and carries no label, so a resolver that demanded one would have broken every floating consumer on the platform at the instant it rolled, before a single object was renamed.
Publishing a second version does not move anybody, and DEFAULT=false is
what guarantees that. Shipping a breaking change and simultaneously moving every
unpinned consumer onto it is precisely the involuntary upgrade this whole scheme
exists to end. When otel-instrumentation 2.0.0 was published, all sixteen of
its consumers stayed on 1.0.0 and nothing was rendered differently.
Publishing a second version:
# ONE type at a new contract version. The object name and the file name are both
# derived from the version; DEFAULT=false keeps unpinned consumers where they are.
ONLY=ruby-service CONTRACT_VERSION=2.0.0 DEFAULT=false SIZES=small,medium,large \
platform/openchoreo-config/component-types/generate.sh
kubectl --context k3d-openchoreo-cp apply \
-f platform/openchoreo-config/component-types/ruby-service-v2.yaml
ONLY= because a new major is almost always one construct: a breaking
change is a decision about one contract, and regenerating the other eight at the
same number would publish eight 2.0.0s that break nothing — a lie about every
one of them, and eight upgrade findings nobody can act on.
The worked example is real. ruby-service 2.0.0 removes xlarge from
sizeClass. A project cell's ResourceQuota caps container memory limits at 6Gi,
and xlarge for this type is 8 × 768Mi = 6144Mi — one container consuming the
entire cell, with nothing left for the worker, the database or the surge replica
a rollout needs. Every promotion asking for it is refused by capacity_check,
always, for arithmetic that cannot change. An option that can never be satisfied
is worse than no option.
The two coexist. A consumer moves when it edits its own manifest — which is a pull request, a diff, a build and an ordinary promotion, exactly like any other change to that component. See "Adopting a new version" below.
The second worked example: a trait, and what a MAJOR is actually worth
otel-instrumentation 2.0.0 removes collectorEndpoint from
environmentConfigs. Its own description read "a platform-owned address;
components never set this" — and an environmentConfig is precisely the knob
for setting something per environment, so the field documented itself as an
option nobody should take. Same reasoning as xlarge, with one difference that
makes it worse: a wrong sizeClass is refused by the capacity check, loudly, at
promotion. A wrong collector endpoint is accepted by everything, the pod starts,
the SDK exports into the void, and the symptom is an absence — no traces, no
error, nothing in any log saying why. The failure mode of the field was the
failure mode the trait exists to prevent.
The address lives in the trait's patch now, so changing the collector is still one edit to one file and still changes no application repository — which was the argument for the field, and it survives the field's removal intact.
Traits freeze exactly like component types, which is worth stating because
the release stores them in two different places and only one of them is obvious.
ComponentRelease.spec.componentProfile.traits[] holds the REFERENCE — kind,
name, instanceName, parameters, and the version that was pinned.
ComponentRelease.spec.traits[], a separate top-level list, holds the frozen
SPEC. Reading only the first gives every appearance of traits not being frozen
at all.
What the major actually did, measured — on 2026-09-06, with chess-render
pinned version: "2" while the other fifteen consumers stayed floating on
1.0.0. Its new release froze a trait spec whose environmentConfigs is
[enabled] — collectorEndpoint gone. Then the same field was set on a binding
for both:
| component | pinned | collectorEndpoint set on its binding |
OTEL_EXPORTER_OTLP_ENDPOINT |
|---|---|---|---|
chess-engine |
floating → 1.0.0 | http://also-evil:4318 |
http://also-evil:4318 |
chess-render |
"2" → 2.0.0 |
http://evil:4318 |
the platform address |
One binding field, one trait instance name, two versions, two outcomes. That is what "removing an environmentConfig is a MAJOR" means, made visible.
chess-render was rolled back to floating the same day, which is why it now
reads 1.0.0 beside the others. Un-adopting is the same ordinary change as
adopting: an edit to the manifest, a re-cut release, and the promotion that
follows it — there is no separate mechanism, which is the property the whole
scheme was built for. chess-opening carries the "2" pin now, so the two
paths stay exercised and the table above stays reproducible.
And a finding: nothing refuses the removed field. The binding carrying
collectorEndpoint under 2.0.0 was accepted, went Ready=True, and the value
was silently ignored. OpenChoreo does not validate traitEnvironmentConfigs
against the trait's schema, so a consumer still setting a field a major removed
gets no error at all — their override simply stops working. The breaking change
is invisible at the exact moment it breaks someone, which is an argument for the
contract diff being read before the merge rather than after.
It needs platform/openchoreo-patches/construct-versioning.patch: upstream's
Component.spec.componentType was self == oldSelf immutable, which freezes the
version along with the identity — so a pin, once made, could never be moved
without deleting the Component and its releases with it.
The supported range
The current major, plus the previous major until the next minor after it.
- a consumer rendered from the current major is supported;
- a consumer rendered from the previous major keeps working and gets fixes, until
the current major reaches its next minor. Then it is on borrowed time and
upgrade-watchsays so.
What this buys is the right to delete code. Without a stated window, every major version ever shipped is supported forever by default, which is how a platform team ends up maintaining four ways to provision Postgres.
A retired version is one whose object is deleted. A consumer still pinned to it
does not break — its ComponentRelease froze the whole spec and keeps rendering
from the frozen copy — but it can no longer cut a NEW release, and the failure
says exactly that rather than "not found".
Adopting a new version
The same git path as any other change to the component, and deliberately so:
upgrade-watchsees a consumer pinned below the newest supported version.- It opens a pull request against the application repository bumping
version:inopenchoreo/component.yaml— the same/actions/open-prpath the upstream-pin bumps already use, with the same per-project opt-in (project-flow.openchoreo.dev/upgrades: pr). - The pull request body carries the contract diff between the two versions:
parameters, environmentConfigs, outputs and validations added, removed or
narrowed. The console renders the same diff at
/platform/component-types/<name>/compare?from=&to=. - Merging it applies the Component, a new
ComponentReleaseis cut, staging picks it up through autoDeploy, and production moves on an ordinary promotion — with the ordinary preview, the ordinary capacity check and the ordinary rollback.
What the diff actually says
/platform/<kind>/<name>/compare?from_version=&to= answers one narrow question:
will my manifest still apply, and will it deploy the same thing?
It is not a diff of two files. Two versions of a ComponentType differ in a few hundred lines of CEL and template YAML and almost none of it is anything a consumer can see — so every field change is classified against the contract defined at the top of this document:
| breaking | removed from the contract, a new required field, a new validation, a narrowed type or pattern, a changed default |
| additive | added to the contract; existing manifests are unaffected |
| implementation | outside the contract entirely |
The asymmetry is the point and it is not arbitrary: adding an optional
parameter is minor and removing one is major, because a manifest survives a
field it does not use and cannot survive one that vanished. The same rule gets
enums right in both directions without a special case — sizeClass gaining
xsmall breaks nobody; losing large breaks everyone who set it.
Implementation rows are shown, not hidden. Switching the objectstore from MinIO to Garage touched nearly every line and was correctly a patch, because nothing in the contract moved — the most instructive thing this platform has done with a version number, and a page that hid those rows could not show it.
Severity is derived, never declared. Nobody types "breaking" anywhere, so the
page reports an implied bump beside the declared one and does not reconcile
them. When they disagree exactly one is wrong; the page has no way to know which
and a person reading it does.
The same computation builds the pull-request body, so what a reviewer reads in Gitea and what the page shows are the same bytes.
The upgrade is a normal deploy. That is the property worth having, and it is why this is a version field rather than a separate migration mechanism.
Which version a release froze
A ComponentRelease already inlined the construct's whole spec; it now also
records the version that produced it:
spec:
componentType:
name: deployment/ruby-service
version: "1.1.0"
spec: {...}
componentProfile:
traits:
- {name: otel-instrumentation, instanceName: otel, version: "1.0.0"}
The spec was always the complete copy, so this adds no fidelity — it adds a NAME for what was copied. Without it, "is production on the current contract" is answerable only by diffing a frozen spec against a live object field by field, which is how the question was avoided rather than answered.
2. Internal libraries
platform_telemetry (Ruby and Python) and platform_capabilities are ordinary
libraries and get ordinary semver over their public API.
Consume them with a compatible-release range, not an exact pin and not a wildcard:
# Python
platform-telemetry = ">=0.4,<0.5" # while 0.x: a minor may break
# and once 1.0 is out:
platform-telemetry = ">=1.2,<2"
# Ruby
gem "platform_telemetry", "~> 0.4.1"
While a library is 0.x, treat minor as the breaking position — that is the
semver convention for 0.x and the reason the Python range above stops at 0.5.
An exact pin is wrong here for the reason ci-uv demonstrated: eight
applications pinned uv exactly, two of them drifted to a different exact version,
and nothing noticed for months because nothing was watching. A range plus a
lockfile gives reproducibility without freezing.
3. Application images are not versioned
They are tagged v1-<sha8> and that is the whole scheme. The tag is a pointer to
a commit, the commit is the version, and main@<sha> in the console is how it is
written down. Every component type carries a validation refusing :latest, so a
floating tag cannot be deployed.
Semver would be actively worse here: it would require someone to decide, per push, what kind of change an application change is — a judgement nobody makes accurately about their own service, and one no consumer of the image needs, because the only consumer is the deployment that was built from it.
Applying this
- Bumping a construct: edit the
openchoreo.dev/versionannotation and everyopenchoreo.dev/rendered-from-versionin the same file, thenplatform/bootstrap/40-platform-config.sh.upgrade-watchfails the pair if they disagree. - The nine generated component types get their version from
component-types/generate.sh, not from their output — editing the generated file is undone by the next run. openchoreo_upgrade_gapcounts constructs that declare no version. It is the number to watch reach zero.