Crossplane Composition and Function Versioning
The Problem
I needed to run multiple versions of a Crossplane composition simultaneously. For example, wdb-v7.0.0 (stable, used by production databases) alongside wdb-v8.0.0 (new features I’m testing). The official Crossplane approach uses OCI packages, but the workflow felt heavy:
- Build package with
crossplane xpkg build - Push to a registry with version tag
- Update every consumer manifest to reference new version
- Hope the version selector actually works (spoiler: it has bugs)
I tried it. The build-push-pull dance added friction to every composition change. Testing a small tweak meant rebuilding and republishing. Running two major versions required managing multiple packages. It felt like solving a simple problem with a complex solution.
The Decision
GitOps-synced versioned manifests for compositions, with automated CI generating both composition and function manifests from templates. Functions are still built as Crossplane xpkg packages and pushed to Harbor (they’re container images, that’s the right delivery mechanism). Composition manifests, on the other hand, are plain YAML files synced by Flux directly from the function repos.
Why Not OCI Packages for Compositions?
Crossplane’s version selection is more complex than it needs to be:
- No simple
version: v1.2.3field – requires labels + selectors - Known bugs with selectors not updating refs
- Managing multiple MAJOR versions is still painful
| Factor | OCI Packages | GitOps + Versioned Manifests |
|---|---|---|
| Complexity | Build → Push → Pull dance | VERSION file bump triggers CI |
| Version selection | Package reference updates | compositionRef.name with exact version |
| Multiple versions | Requires multiple packages | Coexist naturally (wdb-v7.0.0, wdb-v8.0.7) |
| Testing | Complex CI setup | Render tests + Chainsaw E2E in CI |
| Composition delivery | Registry pull | Flux GitRepository sync |
How It Works
Repo Structure
Each composition lives in its own repo with a VERSION file, templates, and generated manifests:
crossplane/wapp/
├── VERSION # e.g. "3.0.3"
├── functions/wapp/ # Go function source code
│ ├── main.go
│ ├── Dockerfile
│ └── package/ # xpkg metadata for crossplane xpkg build
├── manifests/
│ ├── templates/
│ │ ├── composition.tmpl.yaml # Composition template with ${VERSION} placeholders
│ │ ├── function.tmpl.yaml # Function CR template (for cluster deployment)
│ │ └── functions.tmpl.yaml # Function CR template (for render/E2E tests)
│ ├── composition-v3.0.3.yaml # Generated by CI — DO NOT edit
│ ├── function-v3.0.3.yaml # Generated by CI — DO NOT edit
│ ├── xrd.yaml # XRD (defaultCompositionRef auto-updated by CI)
│ └── deployment-runtime-config.yaml
└── tests/
├── render/ # Golden file render tests
└── chainsaw/ # Chainsaw E2E testsThe VERSION File
A single file drives everything:
3.0.3Follows semver: major bumps for breaking XR API changes, minor for new features, patch for fixes.
Template Generation
CI uses envsubst to generate versioned manifests. The composition template references the function by versioned name:
# manifests/templates/composition.tmpl.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: wapp-v${VERSION}
labels:
crossplane.io/xrd: wapps.wapp.example.com
version: v${VERSION}
spec:
compositeTypeRef:
apiVersion: wapp.example.com/v1alpha1
kind: Wapp
mode: Pipeline
pipeline:
- step: create-wapp-resources
functionRef:
name: function-wapp-v${VERSION_DASHED}The function template points to the xpkg in the container registry (Harbor):
# manifests/templates/function.tmpl.yaml
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-wapp-v${VERSION_DASHED}
spec:
package: my-registry.example.com/crossplane/function-wapp/v${VERSION_DASHED}:v${VERSION}VERSION_DASHED replaces dots with dashes (e.g. 3.0.3 becomes 3-0-3) because Kubernetes resource names can’t contain dots.
The CI Pipeline
When VERSION is bumped and pushed to main:
- Detect: Checks if a git tag for the version already exists. If it does, the pipeline runs only tests and skips the release steps
- Generate:
envsubstcreatescomposition-v${VERSION}.yamlandfunction-v${VERSION}.yamlfrom templates. Also updates the XRD’sdefaultCompositionRefto point to the new version - Build & Push: Builds the Go function as a Docker image, wraps it as a Crossplane xpkg (
crossplane xpkg build), and pushes to Harbor with a-devtag for testing - Test (parallel): Go unit tests + render tests (golden file comparison) run alongside Chainsaw E2E tests in Kind clusters
- Release: If all tests pass, pushes the xpkg with the proper version tag (without
-dev) and creates a git tag
How Manifests Reach the Cluster
Flux syncs directly from each function repo. In the Flux monorepo, each composition has:
A GitRepository source pointing to the function repo:
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: crossplane-wapp
spec:
url: https://git.example.com/crossplane/wapp.git
ref:
branch: mainAnd a Kustomization that syncs the ./manifests directory:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: crossplane-wapp
spec:
path: ./manifests
sourceRef:
kind: GitRepository
name: crossplane-wappWhen CI commits the generated manifests and Flux detects the new commit, it syncs both the new Composition and Function CRs to the cluster automatically.
How XRs Select a Version
XRs use compositionRef with the exact versioned composition name:
apiVersion: wapp.example.com/v1alpha1
kind: Wapp
metadata:
name: myapp
spec:
crossplane:
# renovate: composition=crossplane/wapp depName=wapp-myapp
compositionRef:
name: wapp-v3.0.3Renovate watches the composition repo for new git tags and auto-bumps these references via a custom regex manager. The # renovate: comment tells Renovate which repo to watch and how to match the version in the compositionRef name.
XRs without an explicit compositionRef use the XRD’s defaultCompositionRef, which CI updates automatically on each release.
The Full Flow
bump VERSION → push to main
→ CI generates composition + function manifests, updates XRD defaultCompositionRef
→ CI builds function xpkg → pushes to Harbor (-dev tag)
→ CI runs Go tests + render tests + Chainsaw E2E (parallel)
→ All pass → CI pushes proper tag → creates git tag
→ Flux detects new commit → syncs manifests to cluster
→ Renovate detects new version → PRs the Flux repo to update compositionRefs in XRsThe Result
Before (OCI packages):
- Change composition → build → push → wait for sync → test → repeat
- Version bumps required touching every consumer manifest manually
- Running v1 and v2 simultaneously meant managing two separate packages
After (versioned manifests + CI automation):
- Bump
VERSION→ push → CI handles everything - Composition and function always versioned together, no drift
- Flux syncs compositions directly from the source repo
- Renovate auto-bumps all XR compositionRefs across the Flux repo
- Multiple versions coexist naturally
I run this pattern across four compositions (wapp, wdb, wsecret, harbor). It’s not perfect, and I’m not convinced this is the ideal approach, but it works well for my use case and I keep iterating on it.
Trade-offs
Accepted:
- Not using “official” Crossplane packaging for compositions (functions still use xpkg)
- Generated manifests committed to git (clearly marked as generated)
- Each composition needs a GitRepository + Kustomization in Flux
Gained:
- Faster iteration – bump a file, push, done
- Composition and function always versioned together
- Easier testing – render tests and E2E run against the exact same artifacts
- Simpler rollback – revert the VERSION bump or point XRs back to an old
compositionRef - Renovate handles the entire upgrade flow across all consumers