Continuous Integration (CI) is the practice of automatically building, testing, and validating code changes as they are committed to version control. For Python projects, CI ensures code quality, catches bugs early, and maintains consistent development workflows.
Overview
CI/CD (Continuous Integration/Continuous Deployment) automates the software delivery pipeline, from code commits through testing, building, and deployment. Python's ecosystem integrates seamlessly with major CI platforms, enabling teams to maintain high-quality codebases while moving quickly.
This guide covers setting up CI pipelines for Python projects on popular platforms, best practices for testing and deployment, and strategies for optimizing build performance.
Key Concepts
Continuous Integration (CI)
Automated testing and validation of code changes:
- Automated Testing: Run unit, integration, and end-to-end tests
- Code Quality: Lint, format, and security checks
- Build Verification: Ensure code compiles and dependencies resolve
- Fast Feedback: Developers get immediate results on their changes
Continuous Deployment (CD)
Automated delivery of code to production:
- Staging Deployments: Automated deployment to test environments
- Production Releases: Push validated code to production
- Rollback Capability: Quickly revert problematic deployments
- Progressive Delivery: Canary releases, blue-green deployments
CI/CD Pipeline Stages
Typical Python CI/CD pipeline:
- Trigger: Code push, pull request, or scheduled run
- Setup: Install Python, dependencies, tools
- Lint: Check code style and quality (ruff, flake8, pylint)
- Test: Run test suites (pytest, unittest)
- Coverage: Measure test coverage (coverage.py)
- Security: Scan for vulnerabilities (bandit, safety)
- Build: Package application (wheel, Docker image)
- Deploy: Push to staging or production
GitHub Actions
Basic Python Workflow
Create .github/workflows/python-ci.yml:
name: Python CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Lint with ruff
run: |
pip install ruff
ruff check .
- name: Test with pytest
run: |
pip install pytest pytest-cov
pytest --cov=./src --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: true
Advanced GitHub Actions Workflow
name: Advanced Python CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 0' # Weekly dependency checks
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install linting tools
run: |
pip install ruff black isort mypy
- name: Run ruff
run: ruff check .
- name: Check formatting with black
run: black --check .
- name: Check import order
run: isort --check-only .
- name: Type check with mypy
run: mypy src/ --strict
test:
needs: lint
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -e .[dev]
- name: Run tests
run: |
pytest -v --cov=src --cov-report=term --cov-report=xml
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install security tools
run: |
pip install bandit safety pip-audit
- name: Run bandit
run: bandit -r src/
- name: Check dependencies with safety
run: safety check
- name: Audit with pip-audit
run: pip-audit
build:
needs: [test, security]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build package
run: |
pip install build
python -m build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
GitHub Actions with Docker
name: Docker CI
on:
push:
branches: [ main ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
GitHub Actions with UV
Ultra-fast dependency installation:
name: Python CI with UV
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install UV
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Set up Python with UV
run: |
uv venv
source .venv/bin/activate
uv pip install -r requirements.txt
uv pip install pytest pytest-cov ruff
- name: Run tests
run: |
source .venv/bin/activate
pytest --cov=src
Azure DevOps
Basic Azure Pipeline
Create azure-pipelines.yml:
trigger:
branches:
include:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
strategy:
matrix:
Python39:
python.version: '3.9'
Python310:
python.version: '3.10'
Python311:
python.version: '3.11'
Python312:
python.version: '3.12'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '$(python.version)'
displayName: 'Use Python $(python.version)'
- script: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov ruff
displayName: 'Install dependencies'
- script: |
ruff check .
displayName: 'Lint with ruff'
- script: |
pytest --junitxml=junit/test-results.xml --cov=. --cov-report=xml
displayName: 'Run tests'
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFiles: '**/test-*.xml'
testRunTitle: 'Python $(python.version)'
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
Multi-Stage Azure Pipeline
stages:
- stage: Build
displayName: 'Build and Test'
jobs:
- job: Test
pool:
vmImage: 'ubuntu-latest'
strategy:
matrix:
Python311:
python.version: '3.11'
Python312:
python.version: '3.12'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '$(python.version)'
- script: |
pip install -r requirements.txt
pip install pytest pytest-cov
displayName: 'Install dependencies'
- script: |
pytest --junitxml=junit/test-results.xml --cov=src
displayName: 'Test'
- task: PublishTestResults@2
inputs:
testResultsFiles: '**/test-*.xml'
- stage: Security
displayName: 'Security Scanning'
dependsOn: Build
jobs:
- job: SecurityScan
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.12'
- script: |
pip install bandit safety
bandit -r src/ -f json -o bandit-report.json
safety check --json > safety-report.json
displayName: 'Security checks'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(System.DefaultWorkingDirectory)'
artifactName: 'security-reports'
- stage: Deploy
displayName: 'Deploy to Production'
dependsOn: [Build, Security]
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployWeb
environment: 'production'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploying to production"
displayName: 'Deploy'
GitLab CI
Basic GitLab Pipeline
Create .gitlab-ci.yml:
image: python:3.12
stages:
- lint
- test
- security
- build
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- venv/
before_script:
- python -m venv venv
- source venv/bin/activate
- pip install --upgrade pip
- pip install -r requirements.txt
lint:
stage: lint
script:
- pip install ruff black mypy
- ruff check .
- black --check .
- mypy src/
test:
stage: test
parallel:
matrix:
- PYTHON_VERSION: ['3.9', '3.10', '3.11', '3.12']
image: python:${PYTHON_VERSION}
script:
- pip install pytest pytest-cov
- pytest --cov=src --cov-report=xml --cov-report=term
coverage: '/TOTAL.*\s+(\d+%)$/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
security:
stage: security
script:
- pip install bandit safety pip-audit
- bandit -r src/
- safety check
- pip-audit
allow_failure: true
build:
stage: build
script:
- pip install build
- python -m build
artifacts:
paths:
- dist/
only:
- main
deploy:
stage: deploy
script:
- pip install twine
- twine upload dist/*
only:
- main
when: manual
environment:
name: production
GitLab CI with Docker
docker-build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
Jenkins
Jenkinsfile for Python
pipeline {
agent any
environment {
PYTHON_VERSION = '3.12'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Setup') {
steps {
sh '''
python${PYTHON_VERSION} -m venv venv
. venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
'''
}
}
stage('Lint') {
steps {
sh '''
. venv/bin/activate
ruff check .
black --check .
'''
}
}
stage('Test') {
steps {
sh '''
. venv/bin/activate
pytest --junitxml=results.xml --cov=src --cov-report=xml
'''
}
post {
always {
junit 'results.xml'
cobertura coberturaReportFile: 'coverage.xml'
}
}
}
stage('Security') {
steps {
sh '''
. venv/bin/activate
bandit -r src/ -f json -o bandit-report.json
safety check --json > safety-report.json
'''
}
post {
always {
archiveArtifacts artifacts: '*-report.json', allowEmptyArchive: true
}
}
}
stage('Build') {
when {
branch 'main'
}
steps {
sh '''
. venv/bin/activate
python -m build
'''
}
post {
success {
archiveArtifacts artifacts: 'dist/*', fingerprint: true
}
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
input message: 'Deploy to production?', ok: 'Deploy'
sh '''
. venv/bin/activate
twine upload dist/*
'''
}
}
}
post {
always {
cleanWs()
}
}
}
Declarative Pipeline with Docker
pipeline {
agent {
docker {
image 'python:3.12'
args '-v $HOME/.cache/pip:/root/.cache/pip'
}
}
stages {
stage('Install') {
steps {
sh 'pip install -r requirements.txt'
}
}
stage('Test') {
steps {
sh 'pytest --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}
CircleCI
Basic CircleCI Configuration
Create .circleci/config.yml:
version: 2.1
orbs:
python: circleci/python@2.1.1
workflows:
main:
jobs:
- test:
matrix:
parameters:
version: ["3.9", "3.10", "3.11", "3.12"]
jobs:
test:
parameters:
version:
type: string
docker:
- image: cimg/python:<< parameters.version >>
steps:
- checkout
- restore_cache:
keys:
- deps-{{ checksum "requirements.txt" }}
- run:
name: Install dependencies
command: |
pip install -r requirements.txt
pip install pytest pytest-cov ruff
- save_cache:
key: deps-{{ checksum "requirements.txt" }}
paths:
- ~/.cache/pip
- run:
name: Lint
command: ruff check .
- run:
name: Test
command: pytest --junitxml=test-results/junit.xml --cov=src
- store_test_results:
path: test-results
- store_artifacts:
path: test-results
Testing Strategies
Test Matrix
Test across multiple Python versions and dependencies:
# GitHub Actions
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
django-version: ['3.2', '4.0', '4.2']
exclude:
- python-version: '3.9'
django-version: '4.2'
Parallel Testing
# GitLab CI
test:
parallel: 4
script:
- pytest --splits 4 --group $CI_NODE_INDEX
Test Coverage Requirements
# GitHub Actions
- name: Check coverage threshold
run: |
coverage report --fail-under=80
Integration Tests
# Docker Compose for integration tests
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
redis:
image: redis:7
jobs:
integration-test:
services:
postgres: postgres
redis: redis
steps:
- name: Run integration tests
run: pytest tests/integration/
env:
DATABASE_URL: postgresql://postgres:testpass@postgres/testdb
REDIS_URL: redis://redis:6379
Caching Strategies
Caching Dependencies
GitHub Actions:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
GitLab CI:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cache/pip
- venv/
Azure DevOps:
- task: Cache@2
inputs:
key: 'pip | "$(Agent.OS)" | requirements.txt'
path: $(PIP_CACHE_DIR)
Docker Layer Caching
# Multi-stage build with layer caching
FROM python:3.12-slim as base
WORKDIR /app
# Cache dependencies layer
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Application layer
COPY . .
CMD ["python", "app.py"]
Security Scanning
Dependency Scanning
security:
steps:
- name: Check dependencies
run: |
pip install safety pip-audit
safety check --json
pip-audit --format json
Code Security Analysis
- name: Bandit security scan
run: |
pip install bandit
bandit -r src/ -f json -o bandit-report.json
Secret Detection
- name: Detect secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
Best Practices
Pipeline Structure
✅ Do:
- Keep pipelines fast (< 10 minutes ideal)
- Run lint checks before tests
- Use matrix builds for multiple versions
- Cache dependencies aggressively
- Fail fast on critical errors
- Separate build and deploy stages
- Use artifacts for build outputs
❌ Don't:
- Run all tests on every commit (use test selection)
- Install dependencies without caching
- Deploy without manual approval for production
- Ignore security warnings
- Skip linting to save time
Performance Optimization
# Run quick checks first
stages:
- quick-check # < 1 minute
- test # < 5 minutes
- integration # < 10 minutes
- deploy # manual trigger
Environment Variables
# GitHub Actions
env:
PYTHONUNBUFFERED: 1
PYTHONDONTWRITEBYTECODE: 1
PIP_NO_CACHE_DIR: off
PIP_DISABLE_PIP_VERSION_CHECK: on
PIP_DEFAULT_TIMEOUT: 100
Dependency Management
# Use lock files for reproducible builds
- name: Install dependencies
run: |
pip install -r requirements.txt --require-hashes
Test Reporting
# Store test results and coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
Common Patterns
Monorepo Testing
# Only test changed services
- name: Detect changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
api:
- 'services/api/**'
worker:
- 'services/worker/**'
- name: Test API
if: steps.changes.outputs.api == 'true'
run: pytest services/api/tests/
Conditional Deployment
# Deploy to staging on develop, production on main
deploy:
script:
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
deploy_to_production
elif [ "$CI_COMMIT_BRANCH" == "develop" ]; then
deploy_to_staging
fi
Matrix Builds with Exclusions
strategy:
matrix:
python: ['3.9', '3.10', '3.11', '3.12']
os: [ubuntu-latest, windows-latest, macos-latest]
exclude:
# Skip Windows + Python 3.9
- python: '3.9'
os: windows-latest
Nightly Builds
# Run comprehensive tests nightly
on:
schedule:
- cron: '0 2 * * *' # 2 AM daily
workflow_dispatch: # Manual trigger
jobs:
extended-tests:
steps:
- name: Run all tests including slow ones
run: pytest --slow
Troubleshooting
Common Issues
Flaky Tests:
# Retry flaky tests
- name: Run tests with retries
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: pytest
Timeout Issues:
# Set appropriate timeouts
jobs:
test:
timeout-minutes: 30
Dependency Conflicts:
# Use constraint files
- name: Install with constraints
run: pip install -r requirements.txt -c constraints.txt
Cache Invalidation:
# Cache with multiple keys
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Debug Mode
# Enable verbose logging
- name: Run with debug
run: |
set -x # Bash debug mode
pytest -vv --log-cli-level=DEBUG
env:
CI_DEBUG: true
Advanced Workflows
Semantic Versioning
- name: Semantic release
uses: cycjimmy/semantic-release-action@v3
with:
semantic_version: 19
extra_plugins: |
@semantic-release/changelog
@semantic-release/git
Automated Dependency Updates
# Dependabot configuration
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "team-reviewers"
labels:
- "dependencies"
Performance Testing
- name: Run performance benchmarks
run: |
pytest tests/performance/ --benchmark-only
pytest-benchmark compare
See Also
Additional Resources
Documentation
- GitHub Actions Documentation
- Azure Pipelines Documentation
- GitLab CI Documentation
- Jenkins Documentation
- CircleCI Documentation
Tools
- pytest - Testing framework
- coverage.py - Code coverage
- ruff - Fast Python linter
- bandit - Security linter
- safety - Dependency vulnerability scanner
- tox - Test automation
- pre-commit - Git hook management