Skip to content
Crossplane Composition and Function Versioning

Crossplane Composition and Function Versioning

4 min read

The Problem

New to Crossplane? See Why Crossplane for background on compositions, functions, and XRs.

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:

  1. Build package with crossplane xpkg build
  2. Push to a registry with version tag
  3. Update every consumer manifest to reference new version
  4. 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.3 field – requires labels + selectors
  • Known bugs with selectors not updating refs
  • Managing multiple MAJOR versions is still painful
FactorOCI PackagesGitOps + Versioned Manifests
ComplexityBuild → Push → Pull danceVERSION file bump triggers CI
Version selectionPackage reference updatescompositionRef.name with exact version
Multiple versionsRequires multiple packagesCoexist naturally (wdb-v7.0.0, wdb-v8.0.7)
TestingComplex CI setupRender tests + Chainsaw E2E in CI
Composition deliveryRegistry pullFlux 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 tests

The VERSION File

A single file drives everything:

3.0.3

Follows 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:

  1. Detect: Checks if a git tag for the version already exists. If it does, the pipeline runs only tests and skips the release steps
  2. Generate: envsubst creates composition-v${VERSION}.yaml and function-v${VERSION}.yaml from templates. Also updates the XRD’s defaultCompositionRef to point to the new version
  3. 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 -dev tag for testing
  4. Test (parallel): Go unit tests + render tests (golden file comparison) run alongside Chainsaw E2E tests in Kind clusters
  5. 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: main

And 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-wapp

When 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.3

Renovate 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 XRs

The 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
Last updated on