GitHub Actions for Python — CI/CD Pipeline from Scratch
Every serious Python project needs automated testing, linting, and deployment. GitHub Actions makes this free (for public repos) and straightforward. This guide builds a complete CI/CD pipeline — from running pytest on every push to deploying Docker containers and publishing to PyPI.
Your First Workflow
GitHub Actions workflows live in .github/workflows/. Here's the minimal useful one:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
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 dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -v --tb=short
That's it. Every push and PR now triggers tests automatically. Let's make it production-grade.
Matrix Testing (Multiple Python Versions)
Test against multiple Python versions and operating systems to catch compatibility issues early:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
exclude:
# Skip expensive combinations
- os: macos-latest
python-version: "3.10"
- os: windows-latest
python-version: "3.10"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}
restore-keys: ${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run tests with coverage
run: |
pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage
if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v4
with:
file: coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
Linting & Type Checking
Run quality checks in parallel with tests — they're fast and catch different issues:
lint:
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 tools
run: pip install ruff mypy
- name: Ruff lint
run: ruff check src/ tests/
- name: Ruff format check
run: ruff format --check src/ tests/
- name: Type check
run: mypy src/ --ignore-missing-imports
Why Ruff? It replaces flake8 + isort + black in a single tool, and it's 10-100x faster (written in Rust). See our type hints guide for mypy details.
Security Scanning
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: Check dependencies for vulnerabilities
run: |
pip install pip-audit
pip install -r requirements.txt
pip-audit
- name: Check for secrets in code
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
See our security best practices guide for more on securing Python projects.
Docker Build & Push
Build and push Docker images on every release. See our Docker deployment guide for Dockerfile best practices.
# .github/workflows/docker.yml
name: Docker
on:
push:
tags: ["v*"] # Trigger on version tags (v1.0.0, v2.1.3)
jobs:
docker:
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 GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
Publish to PyPI
Automatically publish your package when you create a GitHub Release. See our packaging guide for project setup.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi # Requires environment approval (optional)
permissions:
id-token: write # For trusted publishing
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build tools
run: pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Uses trusted publishing — no API token needed!
# Configure at https://pypi.org/manage/project/.../settings/publishing/
Trusted publishing (OIDC) is the recommended way since 2024 — no API tokens to manage. PyPI verifies the GitHub repo and workflow directly.
Database Integration Tests
Run tests against real databases using service containers:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test
POSTGRES_PASSWORD: test
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
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run integration tests
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
run: pytest tests/integration/ -v -m integration
Deploy on Merge
Deploy to a VPS (SSH)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
if: github.event_name == 'push' # Skip PRs
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/myapp
git pull origin main
docker compose pull
docker compose up -d --remove-orphans
docker compose exec -T api alembic upgrade head
echo "Deployed $(git rev-parse --short HEAD) at $(date)"
Deploy to fly.io
- name: Deploy to Fly.io
uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Workflow Optimization Tips
Conditional jobs
jobs:
# Only run expensive tests if code changed (not just docs)
test:
if: |
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[docs only]')
# Only deploy from main branch
deploy:
needs: [test, lint]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Reusable workflows
# .github/workflows/reusable-test.yml
name: Reusable Test
on:
workflow_call:
inputs:
python-version:
required: false
type: string
default: "3.12"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest tests/ -v
# Use it in other workflows:
# jobs:
# test:
# uses: ./.github/workflows/reusable-test.yml
# with:
# python-version: "3.12"
Concurrency control
# Cancel in-progress runs for the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Complete Production Pipeline
Here's the full workflow combining everything:
# .github/workflows/pipeline.yml
name: Pipeline
on:
push:
branches: [main, develop]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install ruff mypy
- run: ruff check src/ tests/
- run: ruff format --check src/ tests/
- run: mypy src/ --ignore-missing-imports
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: |
pip install pip-audit
pip install -r requirements.txt
pip-audit
test:
needs: lint # Don't waste CI time if lint fails
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }}
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest tests/ -v --cov=src --cov-report=xml
- uses: codecov/codecov-action@v4
if: matrix.python-version == '3.12'
deploy:
needs: [test, security]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
🚀 Want CI/CD templates, automation scripts, and production deployment configs?
Related Articles
- Python Testing Guide — pytest, Mocking & CI Integration
- Dockerize Python Apps — From Development to Production
- Python Packaging — Build & Distribute Your Library
- Python Security Best Practices
- Python Type Hints — Write Safer Code
- Python Microservices — Build, Deploy & Scale
Need help setting up CI/CD for your Python project? I build automation pipelines, testing frameworks, and deployment infrastructure. Reach out on Telegram →