Building a CI/CD Pipeline for Fine-Tuned Models
Fine-tuning large language models and other foundation models has become a cornerstone of modern AI development. However, the workflow that surrounds fine-tuning—experimentation, evaluation, versioning, deployment, and monitoring—is often ad hoc and error-prone. Applying CI/CD (Continuous Integration and Continuous Deployment) principles to fine-tuned models brings the same rigor to ML engineering that software engineering has enjoyed for decades. This tutorial walks through what a CI/CD pipeline for fine-tuned models looks like, why it matters, and how to build one end to end.
What Is a CI/CD Pipeline for Fine-Tuned Models?
A CI/CD pipeline for fine-tuned models is an automated workflow that takes a fine-tuning job from code commit to production deployment, with quality gates along the way. Unlike traditional software CI/CD, which focuses on compiling code and running unit tests, an ML pipeline must also handle datasets, training runs, model artifacts, evaluation metrics, and inference infrastructure.
The pipeline typically consists of these stages:
- Source — Triggered by a commit to the training code, data, or configuration.
- Validation — Linting, type checking, and lightweight unit tests on training scripts.
- Data Preparation — Versioning and validating the training and evaluation datasets.
- Training — Running the fine-tuning job on GPU infrastructure.
- Evaluation — Running the model against a held-out test set and quality benchmarks.
- Registration — Storing the model artifact in a registry with metadata and lineage.
- Deployment — Shipping the model to a staging or production inference endpoint.
- Monitoring — Tracking drift, latency, and output quality post-deployment.
Why It Matters
Without CI/CD, fine-tuned models are typically built through manual notebook workflows. This leads to reproducibility problems, silent regressions, and slow iteration cycles. A proper pipeline gives you several concrete benefits:
- Reproducibility — Every model is tied to a specific commit, dataset version, and hyperparameter set.
- Quality gates — Models cannot reach production unless they pass evaluation thresholds.
- Auditability — Full lineage from data to deployed model for compliance and debugging.
- Speed — Automated training and deployment reduce time-to-production from weeks to hours.
- Safety — Rollbacks are trivial because previous model versions remain in the registry.
How to Build It
In this tutorial, we will build a pipeline using GitHub Actions for orchestration, Hugging Face Transformers and PEFT for fine-tuning, Weights & Biases for experiment tracking, and the Hugging Face Hub as a model registry. The same patterns apply to GitLab CI, Jenkins, or Argo Workflows.
1. Project Structure
Start with a clean repository layout that separates concerns:
finetune-pipeline/
├── .github/
│ └── workflows/
│ ├── train.yml
│ └── deploy.yml
├── src/
│ ├── train.py
│ ├── evaluate.py
│ └── data.py
├── configs/
│ └── base.yaml
├── tests/
│ └── test_data.py
├── requirements.txt
└── README.md
2. The Training Script
The training script should be parameterized so the pipeline can override hyperparameters. Here is a minimal fine-tuning script using LoRA on a small causal language model:
# src/train.py
import argparse
import yaml
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
)
from peft import LoraConfig, get_peft_model
import wandb
def load_config(path):
with open(path, "r") as f:
return yaml.safe_load(f)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--model-id", required=True)
parser.add_argument("--dataset-repo", required=True)
args = parser.parse_args()
cfg = load_config(args.config)
wandb.init(project=cfg["wandb_project"], config=cfg)
tokenizer = AutoTokenizer.from_pretrained(cfg["base_model"])
model = AutoModelForCausalLM.from_pretrained(
cfg["base_model"],
torch_dtype=torch.bfloat16,
device_map="auto",
)
lora_config = LoraConfig(
r=cfg["lora_r"],
lora_alpha=cfg["lora_alpha"],
target_modules=cfg["target_modules"],
lora_dropout=cfg["lora_dropout"],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
dataset = load_dataset(args.dataset_repo, split="train")
def tokenize(example):
return tokenizer(example["text"], truncation=True, max_length=512)
tokenized = dataset.map(tokenize, batched=True)
training_args = TrainingArguments(
output_dir="./outputs",
num_train_epochs=cfg["epochs"],
per_device_train_batch_size=cfg["batch_size"],
learning_rate=cfg["learning_rate"],
logging_steps=10,
save_strategy="epoch",
report_to="wandb",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized,
)
trainer.train()
# Save the adapter
model.save_pretrained("./outputs/adapter")
tokenizer.save_pretrained("./outputs/adapter")
wandb.finish()
if __name__ == "__main__":
main()
3. The Evaluation Script
Evaluation is the quality gate. The pipeline should fail if the model does not meet a minimum threshold. Here we compute perplexity and a simple accuracy metric on a held-out set:
# src/evaluate.py
import argparse
import json
import math
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
def compute_perplexity(model, tokenizer, texts, max_length=512):
model.eval()
total_loss = 0.0
count = 0
with torch.no_grad():
for text in texts:
enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
enc = {k: v.to(model.device) for k, v in enc.items()}
outputs = model(**enc, labels=enc["input_ids"])
total_loss += outputs.loss.item()
count += 1
avg_loss = total_loss / max(count, 1)
return math.exp(avg_loss)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base-model", required=True)
parser.add_argument("--adapter-path", required=True)
parser.add_argument("--eval-dataset", required=True)
parser.add_argument("--max-perplexity", type=float, default=50.0)
parser.add_argument("--output", default="metrics.json")
args = parser.parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.base_model)
base = AutoModelForCausalLM.from_pretrained(
args.base_model, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, args.adapter_path)
eval_ds = load_dataset(args.eval_dataset, split="test")
texts = eval_ds["text"][:100]
ppl = compute_perplexity(model, tokenizer, texts)
metrics = {"perplexity": ppl, "passed": ppl < args.max_perplexity}
with open(args.output, "w") as f:
json.dump(metrics, f, indent=2)
print(json.dumps(metrics, indent=2))
if not metrics["passed"]:
raise SystemExit("Evaluation failed: perplexity above threshold")
if __name__ == "__main__":
main()
Note the explicit SystemExit when the metric fails. This non-zero exit code is what allows the CI runner to halt the pipeline before deployment.
4. The GitHub Actions Workflow
The workflow below triggers on pushes to main, runs tests, launches a GPU training job, evaluates the result, and pushes the adapter to the Hugging Face Hub only if evaluation passes.
# .github/workflows/train.yml
name: Fine-Tune Pipeline
on:
push:
branches: [main]
paths:
- "src/**"
- "configs/**"
- ".github/workflows/train.yml"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt ruff pytest
- run: ruff check src/
- run: pytest tests/
train-and-evaluate:
needs: validate
runs-on: self-hosted-gpu
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- name: Fine-tune model
env:
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
run: |
python src/train.py \
--config configs/base.yaml \
--model-id run-${{ github.sha }} \
--dataset-repo my-org/finetune-dataset
- name: Evaluate model
run: |
python src/evaluate.py \
--base-model mistralai/Mistral-7B-v0.1 \
--adapter-path ./outputs/adapter \
--eval-dataset my-org/finetune-eval \
--max-perplexity 45.0
- name: Upload metrics artifact
uses: actions/upload-artifact@v4
with:
name: metrics
path: metrics.json
- name: Push adapter to Hub
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
pip install huggingface_hub
python -c "
from huggingface_hub import HfApi
api = HfApi()
api.create_branch('my-org/finetuned-model', branch='run-${{ github.sha }}', exist_ok=True)
api.upload_folder(
folder_path='./outputs/adapter',
repo_id='my-org/finetuned-model',
revision='run-${{ github.sha }}',
commit_message='Auto-deploy from ${{ github.sha }}'
)
"
5. The Deployment Stage
Deployment is a separate workflow that promotes a specific model revision to a production endpoint. Keeping it separate lets you decouple training cadence from release cadence.
# .github/workflows/deploy.yml
name: Deploy Model
on:
workflow_dispatch:
inputs:
revision:
description: "Model revision (commit sha) to deploy"
required: true
environment:
description: "Target environment"
type: choice
options: [staging, production]
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy to inference server
env:
INFERENCE_API_KEY: ${{ secrets.INFERENCE_API_KEY }}
run: |
curl -X POST https://inference.my-org.internal/v1/models \
-H "Authorization: Bearer $INFERENCE_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"model_repo\": \"my-org/finetuned-model\",
\"revision\": \"run-${{ github.event.inputs.revision }}\",
\"environment\": \"${{ github.event.inputs.environment }}\"
}"
- name: Run smoke test
run: |
python tests/smoke_test.py --endpoint https://inference.my-org.internal/v1
6. Smoke Testing the Endpoint
After deployment, a smoke test confirms the endpoint is actually serving the new model and producing sane outputs:
# tests/smoke_test.py
import argparse
import requests
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", required=True)
args = parser.parse_args()
payload = {
"inputs": "Explain CI/CD in one sentence.",
"parameters": {"max_new_tokens": 50, "temperature": 0.1},
}
resp = requests.post(args.endpoint, json=payload, timeout=30)
resp.raise_for_status()
output = resp.json()
text = output[0]["generated_text"] if isinstance(output, list) else str(output)
assert len(text) > 10, "Output too short, model may be broken"
print("Smoke test passed:", text[:120])
if __name__ == "__main__":
main()
Best Practices
- Version your data — Use DVC, Hugging Face Datasets, or a feature store. A model is only reproducible if its training data is pinned.
- Pin all dependencies — Lock
transformers,torch,peft, and CUDA versions. A silent upgrade can change numerics and break reproducibility. - Use separate runners for GPU jobs — CPU validation can run on hosted runners; training should run on self-hosted GPU instances or cloud spot instances.
- Set hard evaluation thresholds — A pipeline that always passes is useless. Choose metrics that reflect real-world quality, not just loss.
- Keep training and deployment decoupled — Not every successful training run should auto-deploy to production. Use manual approval gates for production.
- Tag every artifact — Include the commit SHA, dataset hash, and config file in model card metadata so lineage is always traceable.
- Cache aggressively — Cache pip downloads, Hugging Face model downloads, and tokenized datasets between runs to cut costs.
- Monitor after deployment — Track input distribution drift, output toxicity, latency, and error rates. Feed anomalies back into the pipeline as retraining triggers.
- Store secrets securely — Never commit API keys. Use GitHub Actions secrets or a vault, and scope tokens to the minimum required permissions.
- Write a model card on every push — Auto-generate a model card with training config, metrics, and intended use so downstream consumers understand the artifact.
Conclusion
Building a CI/CD pipeline for fine-tuned models transforms ML development from a fragile, manual craft into a disciplined engineering practice. By automating validation, training, evaluation, registration, and deployment—with hard quality gates at each stage—you gain reproducibility, safety, and speed simultaneously. The pipeline shown in this tutorial is intentionally simple, but the same architecture scales to multi-GPU distributed training, canary deployments, A/B testing of model variants, and automated retraining triggered by drift detection. Start small, enforce your evaluation thresholds ruthlessly, and iterate on the pipeline itself just as you iterate on your models.