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:

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:

MINOR — new capability, every existing consumer unaffected:

PATCH — no contract change at all:

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.

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:

  1. upgrade-watch sees a consumer pinned below the newest supported version.
  2. It opens a pull request against the application repository bumping version: in openchoreo/component.yaml — the same /actions/open-pr path the upstream-pin bumps already use, with the same per-project opt-in (project-flow.openchoreo.dev/upgrades: pr).
  3. 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=.
  4. Merging it applies the Component, a new ComponentRelease is 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