CI/CD Pipelines for Crossplane Functions
Crossplane Python Functions | Part 8
Building a production multi-cloud platform with Python
You've built a Python composition function. It works locally. Now you need to deploy it reliably—build container images, run tests, and push to registries automatically.
This post covers production-ready CI/CD pipelines for Crossplane functions using GitHub Actions.
What We're Building
A complete pipeline that:
- Runs unit tests on every push
- Validates with
crossplane beta renderto catch composition errors - Builds multi-platform images for amd64 and arm64
- Pushes to private registries (GCP Artifact Registry, AWS ECR, or Azure ACR)
- Creates Crossplane packages (.xpkg files)
- Supports semantic versioning with Git tags

Repository Structure
function-myplatform/
├── .github/
│ └── workflows/
│ ├── ci.yaml # Test on PR
│ ├── build-push.yaml # Build and push on merge
│ └── release.yaml # Release on tag
├── myplatform/
│ ├── __init__.py
│ ├── core.py
│ ├── resources/
│ └── function.py
├── function/
│ └── main.py
├── tests/
│ ├── test_composition.py
│ └── fixtures/
├── examples/
│ ├── claim.yaml
│ ├── composition.yaml
│ └── functions.yaml
├── package/
│ └── crossplane.yaml
├── Dockerfile
├── pyproject.toml
└── hatch.toml
Workflow 1: CI on Pull Requests
Run tests and validation on every PR:
# .github/workflows/ci.yaml
name: CI
on:
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install hatch
- name: Run linting
run: hatch run lint:all
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install hatch
- name: Run unit tests
run: hatch run test:unit
- name: Run integration tests
run: hatch run test:integration
render-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install hatch
- name: Install Crossplane CLI
run: |
curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh
sudo mv crossplane /usr/local/bin/
- name: Start function server
run: |
hatch run development &
sleep 5
- name: Test GCP render
run: |
crossplane beta render \
examples/gcp/claim.yaml \
apis/cluster/composition.yaml \
examples/functions.yaml \
-e examples/envconfigs/ \
-r
- name: Test AWS render
run: |
crossplane beta render \
examples/aws/claim.yaml \
apis/cluster/composition.yaml \
examples/functions.yaml \
-e examples/envconfigs/ \
-r
- name: Test Azure render
run: |
crossplane beta render \
examples/azure/claim.yaml \
apis/cluster/composition.yaml \
examples/functions.yaml \
-e examples/envconfigs/ \
-r
Workflow 2: Build and Push on Merge
Build images when PRs are merged to main:
# .github/workflows/build-push.yaml
name: Build and Push
on:
push:
branches: [main]
env:
REGISTRY: us-docker.pkg.dev
PROJECT_ID: my-project
REPOSITORY: crossplane-functions
IMAGE_NAME: function-myplatform
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # For Workload Identity
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# GCP Authentication with Workload Identity
- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com
- name: Configure Docker for Artifact Registry
run: |
gcloud auth configure-docker ${{ env.REGISTRY }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Crossplane CLI
run: |
curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh
sudo mv crossplane /usr/local/bin/
- name: Build Crossplane package
run: |
crossplane xpkg build \
-f package/ \
--embed-runtime-image=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-o function-myplatform-${{ github.sha }}.xpkg
- name: Push Crossplane package
run: |
crossplane xpkg push \
-f function-myplatform-${{ github.sha }}.xpkg \
${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
Workflow 3: Release on Tag
Create versioned releases:
# .github/workflows/release.yaml
name: Release
on:
push:
tags:
- 'v*'
env:
REGISTRY: us-docker.pkg.dev
PROJECT_ID: my-project
REPOSITORY: crossplane-functions
IMAGE_NAME: function-myplatform
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com
- name: Configure Docker
run: gcloud auth configure-docker ${{ env.REGISTRY }}
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Crossplane CLI
run: |
curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh
sudo mv crossplane /usr/local/bin/
- name: Build Crossplane package
run: |
crossplane xpkg build \
-f package/ \
--embed-runtime-image=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
-o function-myplatform-${{ steps.version.outputs.VERSION }}.xpkg
- name: Push Crossplane package
run: |
crossplane xpkg push \
-f function-myplatform-${{ steps.version.outputs.VERSION }}.xpkg \
${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: function-myplatform-${{ steps.version.outputs.VERSION }}.xpkg
generate_release_notes: true
Authentication Options
GCP Artifact Registry with Workload Identity
# Recommended: Workload Identity (no long-lived credentials)
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@PROJECT_ID.iam.gserviceaccount.com
- name: Configure Docker
run: gcloud auth configure-docker us-docker.pkg.dev
Setup in GCP:
# Create workload identity pool
gcloud iam workload-identity-pools create github \
--location=global \
--display-name="GitHub Actions"
# Create provider
gcloud iam workload-identity-pools providers create-oidc github \
--location=global \
--workload-identity-pool=github \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository"
# Grant permissions
gcloud iam service-accounts add-iam-policy-binding \
github-actions@PROJECT_ID.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/Marouan-chak/function-myplatform"
AWS ECR with OIDC
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions
aws-region: us-east-1
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
Azure ACR with Workload Identity
- name: Azure login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Login to ACR
run: az acr login --name myregistry
The Dockerfile
Optimized multi-stage build:
# Build stage
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN pip install --no-cache-dir hatch
# Copy project files
COPY pyproject.toml hatch.toml ./
COPY myplatform/ myplatform/
COPY function/ function/
# Build the wheel
RUN hatch build
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
# Install runtime dependencies only
COPY --from=builder /app/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl
# Copy entrypoint
COPY function/main.py .
# Run as non-root user
RUN useradd -r -u 1000 function
USER function
EXPOSE 9443
ENTRYPOINT ["python", "main.py"]
Package Metadata
Configure package/crossplane.yaml:
apiVersion: meta.pkg.crossplane.io/v1
kind: Function
metadata:
name: function-myplatform
annotations:
meta.crossplane.io/maintainer: Platform Team <platform@example.com>
meta.crossplane.io/source: https://github.com/Marouan-chak/function-myplatform
meta.crossplane.io/description: |
Multi-cloud platform composition function supporting GCP, AWS, and Azure.
meta.crossplane.io/readme: |
This function creates cloud-agnostic Kubernetes clusters and supporting
infrastructure across GCP, AWS, and Azure.
spec:
crossplane:
version: ">=1.14.0"
Version Management
Semantic Versioning
Use Git tags for versions:
# Create version tag
git tag v1.0.0
git push origin v1.0.0
# This triggers the release workflow
Automatic Version Bumping
# .github/workflows/bump-version.yaml
name: Bump Version
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get current version
id: current
run: |
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "VERSION=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Bump version
id: bump
run: |
IFS='.' read -r major minor patch <<< "${{ steps.current.outputs.VERSION }}"
case "${{ inputs.bump }}" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
echo "VERSION=${major}.${minor}.${patch}" >> $GITHUB_OUTPUT
- name: Create tag
run: |
git config user.name github-actions
git config user.email github-actions@github.com
git tag v${{ steps.bump.outputs.VERSION }}
git push origin v${{ steps.bump.outputs.VERSION }}
Testing in CI
Unit Tests
# tests/test_composition.py
import pytest
from myplatform.core import add_definition
from myplatform.resources.bucket import add_bucket
def test_add_bucket_gcp():
"""Test GCP bucket creation."""
composition = MockComposition(cloud="gcp")
add_bucket(
composition=composition,
name="test-bucket",
external_name="my-bucket",
force_destroy=True,
)
# Verify bucket was added
assert "test-bucket" in composition.rsp.desired.resources
resource = composition.rsp.desired.resources["test-bucket"].resource
assert resource["apiVersion"] == "storage.gcp.upbound.io/v1beta2"
Render Tests
# In CI workflow
- name: Test render produces expected resources
run: |
OUTPUT=$(crossplane beta render \
examples/gcp/claim.yaml \
apis/cluster/composition.yaml \
examples/functions.yaml \
-e examples/envconfigs/)
# Verify expected resources exist
echo "$OUTPUT" | grep -q "kind: Cluster"
echo "$OUTPUT" | grep -q "kind: NodePool"
echo "$OUTPUT" | grep -q "kind: Release"
Promotion Strategy
Environment Branches
main -> dev registry
release/v1.x -> staging registry
tags v* -> production registry
Workflow per Environment
# Deploy to dev on push to main
on:
push:
branches: [main]
env:
REGISTRY: dev-registry.example.com
---
# Deploy to staging on release branch
on:
push:
branches: [release/*]
env:
REGISTRY: staging-registry.example.com
---
# Deploy to prod on tags
on:
push:
tags: [v*]
env:
REGISTRY: prod-registry.example.com
Monitoring Builds
Slack Notifications
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
channel-id: 'C0123456789'
slack-message: |
Build failed for ${{ github.repository }}
Commit: ${{ github.sha }}
Author: ${{ github.actor }}
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
Build Status Badge
Add to README.md:


Key Takeaways
- Run
crossplane beta renderin CI to catch composition errors early - Use multi-platform builds (amd64 + arm64) for broad compatibility
- Workload Identity avoids long-lived credentials in CI
- Semantic versioning with tags triggers release builds
- Cache Docker layers with
cache-from: type=ghafor faster builds - Separate workflows for CI, build, and release keeps concerns clear
Next Up
In Part 9, we'll explore importing existing infrastructure—how to adopt cloud resources that weren't created by Crossplane using observe and manage modes.
Written by Marouan Chakran, Senior SRE and Platform Engineer, building multi-cloud platforms with Crossplane and Python.
Part 8 of 10 | Previous: Helm Templating with Jinja2 | Next: Importing Existing Infrastructure
Companion repository: github.com/Marouan-chak/crossplane-python-blog-series
Tags: crossplane, platform-engineering, kubernetes, python, devops