CI/CD Pipeline Overview
┌──────────┐
Push / PR ──────▶│ Lint │───❌───▶ Fix and retry
└────┬─────┘
│ ✅
┌────▼─────┐
│ Test │───❌───▶ Fix failing tests
└────┬─────┘
│ ✅
┌────▼──────┐
│ Build │
│ Docker │
└────┬──────┘
│ ✅
┌────▼──────┐
│ Deploy │───▶ Production / Staging
└───────────┘
Each stage gates the next - a failed lint blocks tests, failed tests block the build.
Pre-commit Hooks
Run checks locally before code reaches CI:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
args: ["--maxkb=500"]
- id: check-json
- id: check-toml
- id: check-yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
additional_dependencies: [pydantic, fastapi, types-redis]
- repo: https://github.com/pycqa/bandit
rev: 1.7.9
hooks:
- id: bandit
args: [-c, pyproject.toml]
additional_dependencies: ["bandit[toml]"]
Install and run:
pip install pre-commit
pre-commit install
pre-commit run --all-files
# Example output
ruff.....................................................................Passed
ruff-format.............................................................Passed
mypy.....................................................................Passed
bandit...................................................................Passed
trailing-whitespace.....................................................Passed
end-of-file-fixer.......................................................Passed
check-added-large-files.................................................Passed
Pytest Configuration
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --tb=short --strict-markers"
[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.",
"raise AssertionError",
"raise NotImplementedError",
]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "ANN", "B", "SIM"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
FastAPI Test Client
# tests/conftest.py
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.config import Settings
@pytest.fixture
def settings():
return Settings(_env_file=".env.test")
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
def sample_payload():
return {"title": "Test Post", "content": "Test content"}
# tests/test_routes.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_post(client: AsyncClient, sample_payload: dict):
response = await client.post("/posts", json=sample_payload)
assert response.status_code == 201
data = response.json()
assert data["title"] == sample_payload["title"]
@pytest.mark.asyncio
async def test_get_nonexistent_post(client: AsyncClient):
response = await client.get("/posts/9999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_health_check(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
# Run tests locally
pytest
# With coverage
pytest --cov=app --cov-report=term-missing
# With HTML report
pytest --cov=app --cov-report=html
GitHub Actions Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PYTHON_VERSION: "3.12"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff mypy pre-commit
- name: Run ruff
run: ruff check --output-format=github .
- name: Run mypy
run: mypy app/
- name: Run pre-commit hooks
run: pre-commit run --all-files --show-diff-on-failure
test:
needs: lint
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx pytest-cov
- name: Run tests with coverage
env:
DATABASE_URL: postgresql+asyncpg://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key
ENVIRONMENT: test
run: |
pytest --cov=app --cov-report=xml --cov-report=term-missing --junitxml=junit.xml
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: junit.xml
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build-and-push
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Deploy to Staging
run: |
# Example: Deploy to Railway via CLI
npm install -g @railway/cli
railway up --service fastapi-app --environment staging
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
deploy-production:
needs: build-and-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to Production
run: |
# Example: Update Kubernetes deployment
kubectl set image deployment/fastapi-app \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
env:
KUBECONFIG: ${{ secrets.KUBECONFIG }}
Test Coverage Gates
# In the test job, add a coverage threshold
- name: Check coverage threshold
run: |
coverage=$(grep -oP 'TOTAL.*\K(\d+)%' coverage.xml | tr -d '%')
if [ "$coverage" -lt 80 ]; then
echo "Coverage $coverage% is below threshold of 80%"
exit 1
fi
Or use a tool like diff-cover to only check new code:
pip install diff-cover
diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
Branch Strategy
main ──────────────► Production deploy (tagged)
▲
│
develop ───────────► Staging deploy
▲
│
feature/* ─────────► PR to develop
▲
│
fix/* ─────────────► PR to develop
| Branch | CI Trigger | Deploy To |
|---|---|---|
feature/* | Lint + Test | - |
develop | Lint + Test + Build | Staging |
main | Lint + Test + Build | Production |
Tags (v*) | Build + Push + Deploy | Production |
Environment-Specific Testing
# .env.test (sourced by pytest)
DATABASE_URL=postgresql+asyncpg://testuser:testpass@localhost:5432/testdb
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=test-secret-key
ENVIRONMENT=test
DEBUG=true
CORS_ORIGINS=http://localhost:3000
Test settings override any .env file:
# tests/conftest.py
@pytest.fixture(autouse=True)
def override_settings():
from app.config import settings
settings.DATABASE_URL = "postgresql+asyncpg://testuser:testpass@localhost:5432/testdb"
settings.SECRET_KEY = "test-only-key"
settings.ENVIRONMENT = "test"
Docker Build Caching
The CI workflow uses GitHub Actions cache (type=gha) for Docker layers:
- name: Build and push
uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
This reuses unchanged layers from previous builds, cutting build times from minutes to seconds.
Rollback Strategy
# Add a rollback job to your workflow
rollback:
needs: deploy-production
if: failure()
runs-on: ubuntu-latest
steps:
- name: Rollback to previous version
run: |
kubectl rollout undo deployment/fastapi-app
CI/CD Checklist
- Pre-commit hooks installed in repo (ruff, mypy, bandit)
- Pytest configured with async support, coverage, and JUnit XML
- CI runs lint → test → build - each stage gates the next
- Docker build uses layer caching for speed
- Secrets stored in GitHub Actions secrets, never in code
- Deploy jobs scoped to protected environments (production requires approval)
- Test coverage threshold enforced (≥80%)
- Rollback mechanism documented or automated
Key Takeaways
- Gate every stage - lint fails before tests run, tests fail before the image builds, build fails before deploy triggers
- Use pre-commit hooks (ruff, mypy, bandit) to catch issues locally before they reach CI - this saves minutes per iteration
- Run FastAPI tests with httpx’s AsyncClient against a real test database in CI service containers (Postgres, Redis)
- Build Docker images with layer caching (
type=gha) to keep build times under a minute for small changes - Protect production deploys with GitHub Environments, manual approval gates, and automated rollback on failure
- Enforce a minimum test coverage threshold (≥80%) and use
diff-coverto only require coverage on new code