CI/CD for ML Pipelines: Automating the Path to Production
Software CI/CD is well understood. ML CI/CD is trickier - you are testing models, not just code. Here is how to build a pipeline that catches problems before they reach production.
Traditional software CI/CD is relatively straightforward: run tests, check types, build, deploy. ML CI/CD adds three new dimensions:
- Data validation - is the training data still what we expect?
- Model evaluation - is the new model actually better?
- Behavioral testing - does the model pass sanity checks beyond aggregate metrics?
Building a good ML CI/CD pipeline means automating all three.
The ML CI/CD Pipeline Structure
Code push to main
│
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 1: Code Quality │
│ - lint (ruff) + type check (mypy) + unit tests │
│ - Fast: < 5 minutes │
└──────────────────────────────────────────────────────────┘
│ pass
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 2: Data Validation │
│ - Schema checks on training data │
│ - Distribution checks (no sudden shifts) │
│ - Fast: < 10 minutes │
└──────────────────────────────────────────────────────────┘
│ pass
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 3: Model Training + Evaluation │
│ - Train on validated data │
│ - Evaluate against performance thresholds │
│ - Compare to current production model │
│ - Slow: 10 minutes to hours depending on model │
└──────────────────────────────────────────────────────────┘
│ pass + beats production
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 4: Behavioral / Slice Testing │
│ - Invariance tests │
│ - Directional tests │
│ - Slice performance tests │
│ - Fast: < 10 minutes │
└──────────────────────────────────────────────────────────┘
│ pass
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 5: Deploy to Staging + Smoke Test │
│ - Container build + push │
│ - Deploy to staging environment │
│ - Integration test against staging │
└──────────────────────────────────────────────────────────┘
│ pass
▼
Deploy to Production (manual approval gate or automatic)
GitHub Actions Implementation
yaml# .github/workflows/ml-pipeline.yml name: ML CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: code-quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: '3.11' cache: 'pip' - run: pip install -r requirements-dev.txt - run: ruff check src/ - run: mypy src/ --ignore-missing-imports - run: pytest tests/unit/ -v --tb=short data-validation: needs: code-quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: '3.11' cache: 'pip' - run: pip install -r requirements.txt - name: Validate training data env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: python scripts/validate_data.py train-and-evaluate: needs: data-validation runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: '3.11' cache: 'pip' - run: pip install -r requirements.txt - name: Train model run: python scripts/train.py --output-dir /tmp/model_artifacts - name: Evaluate against thresholds run: python scripts/evaluate.py --model-dir /tmp/model_artifacts --fail-below-auc 0.80 - name: Compare to production run: python scripts/compare_to_production.py --challenger-dir /tmp/model_artifacts - name: Upload artifacts uses: actions/upload-artifact@v3 with: name: model-artifacts path: /tmp/model_artifacts/ behavioral-tests: needs: train-and-evaluate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: name: model-artifacts path: /tmp/model_artifacts/ - run: pip install -r requirements.txt - run: pytest tests/behavioral/ -v --model-dir /tmp/model_artifacts deploy-staging: needs: behavioral-tests runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: name: model-artifacts path: model_artifacts/ - name: Build and push Docker image run: | docker build -t ${{ env.REGISTRY }}/churn-predictor:${{ github.sha }} . docker push ${{ env.REGISTRY }}/churn-predictor:${{ github.sha }} - name: Deploy to staging run: ./scripts/deploy.sh staging ${{ github.sha }} - name: Smoke test staging run: pytest tests/integration/ --base-url https://staging.api.example.com
Data Validation Script
python# scripts/validate_data.py import pandas as pd import numpy as np import sys import json def validate_training_data(data_path: str, schema_path: str) -> bool: df = pd.read_parquet(data_path) with open(schema_path) as f: schema = json.load(f) errors = [] # Schema validation for col, expected_type in schema['columns'].items(): if col not in df.columns: errors.append(f"Missing column: {col}") elif str(df[col].dtype) != expected_type: errors.append(f"Column {col}: expected {expected_type}, got {df[col].dtype}") # Volume check min_rows = schema.get('min_rows', 1000) if len(df) < min_rows: errors.append(f"Too few rows: {len(df)} < {min_rows}") # Distribution checks (compare to baseline statistics) with open('data/baseline_stats.json') as f: baseline = json.load(f) for col in schema.get('monitored_columns', []): current_mean = df[col].mean() baseline_mean = baseline[col]['mean'] baseline_std = baseline[col]['std'] z_score = abs(current_mean - baseline_mean) / (baseline_std + 1e-10) if z_score > 3: errors.append(f"Severe distribution shift in {col}: z-score={z_score:.2f}") if errors: print("DATA VALIDATION FAILED:") for e in errors: print(f" - {e}") return False print(f"Data validation passed: {len(df)} rows, {len(df.columns)} columns") return True if __name__ == "__main__": success = validate_training_data( "s3://ml-data/training/latest.parquet", "data/schema.json" ) sys.exit(0 if success else 1)
Behavioral Testing
Aggregate AUC does not catch all failure modes. Behavioral tests check specific behaviors:
python# tests/behavioral/test_model_behavior.py import pytest import joblib import numpy as np @pytest.fixture def model(request): model_dir = request.config.getoption("--model-dir") return joblib.load(f"{model_dir}/pipeline.joblib") def make_customer(age=30, income=60000, tenure_months=12): return np.array([[age, income, tenure_months]]) class TestInvariance: """Small, irrelevant input changes should not change prediction much.""" def test_income_noise_stability(self, model): base = make_customer(income=60000) noisy = make_customer(income=60001) # $1 difference base_pred = model.predict_proba(base)[0][1] noisy_pred = model.predict_proba(noisy)[0][1] assert abs(base_pred - noisy_pred) < 0.01 class TestDirectional: """Changing a feature in a known direction should move prediction accordingly.""" def test_longer_tenure_reduces_churn(self, model): short_tenure = make_customer(tenure_months=1) long_tenure = make_customer(tenure_months=60) assert (model.predict_proba(long_tenure)[0][1] < model.predict_proba(short_tenure)[0][1]) def test_higher_income_reduces_churn(self, model): low_income = make_customer(income=20000) high_income = make_customer(income=200000) assert (model.predict_proba(high_income)[0][1] < model.predict_proba(low_income)[0][1]) class TestEdgeCases: def test_handles_zero_tenure(self, model): pred = model.predict_proba(make_customer(tenure_months=0)) assert 0 <= pred[0][1] <= 1 # valid probability def test_handles_extreme_income(self, model): pred = model.predict_proba(make_customer(income=10_000_000)) assert 0 <= pred[0][1] <= 1
These tests encode business knowledge. A model that predicts higher churn for higher income customers is almost certainly wrong, even if its AUC looks fine.
The Production Comparison Gate
The most important check: does the new model actually beat production?
python# scripts/compare_to_production.py import mlflow import joblib import sys import numpy as np from sklearn.metrics import roc_auc_score def compare_to_production(challenger_dir: str, holdout_data: str) -> bool: # Load challenger (newly trained model) challenger = joblib.load(f"{challenger_dir}/pipeline.joblib") # Load champion (current production model) client = mlflow.tracking.MlflowClient() champion_uri = "models:/churn-predictor/Production" champion = mlflow.sklearn.load_model(champion_uri) # Evaluate both on holdout set X_hold, y_hold = load_holdout_data(holdout_data) challenger_auc = roc_auc_score(y_hold, challenger.predict_proba(X_hold)[:, 1]) champion_auc = roc_auc_score(y_hold, champion.predict_proba(X_hold)[:, 1]) print(f"Champion AUC: {champion_auc:.4f}") print(f"Challenger AUC: {challenger_auc:.4f}") print(f"Delta: {challenger_auc - champion_auc:+.4f}") # Require challenger to beat champion by at least 0.5% if challenger_auc >= champion_auc + 0.005: print("RESULT: Challenger wins - promoting to staging") return True else: print("RESULT: Challenger did not beat champion - blocking deployment") return False if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--challenger-dir", required=True) args = parser.parse_args() success = compare_to_production(args.challenger_dir, "data/holdout.parquet") sys.exit(0 if success else 1)
This gate means you only deploy when a model is provably better, not just "recently retrained."
What to Practice Next
- Add a GitHub Actions workflow to an existing ML repo that runs
pyteston your data validation and model evaluation code on every pull request - start with unit tests, then add integration tests. - Implement a simple model quality gate: after training, assert that validation accuracy exceeds a threshold before the workflow proceeds to the push/deploy step; treat a regression as a build failure.
- Compare DVC and MLflow Model Registry for artifact versioning in a small experiment - document which abstraction layer each operates at and when you would reach for one over the other.
Stay in the loop
Get new ML/AI lessons in your inbox.
No account needed. We will send curriculum updates, launch notes, and practical learning resources.
Related Posts
More postsOpen-Weight and Small Models in 2026: When to Self-Host
Open-weight models are competitive, small models run on a phone, and the API-for-everything default is no longer obviously right. Here is a decision framework for self-hosting versus API, where small models win, what mixture-of-experts changes about the parameter count, and the hybrid most teams end up with.
ML Model to Production: A Complete Walkthrough
Most ML models die in notebooks. Walk through the full path from trained model to live API endpoint serving real traffic - packaging, containerizing, deploying, and monitoring.
Model Versioning with MLflow: Practical Guide
Without model versioning, you cannot reproduce results, roll back broken deployments, or compare experiments. MLflow gives you a practical registry - here is how to use it well.