Python GitHub Actions: CI/CD Pipeline for Python Apps
GitHub Actions is the most popular CI/CD platform for Python projects. With YAML workflow files committed alongside your code, every push and pull request can automatically run tests, lint code, build Docker images, and deploy to production. This guide covers a production-grade CI/CD setup: matrix testing across Python versions, dependency caching, code coverage, Docker builds, PyPI publishing with trusted publishing, and zero-downtime deployment.
Table of Contents
Basic CI: Test on Every Push
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: --health-cmd "redis-cli ping"
ports:
- 6379:6379
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key-not-for-prod
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 --upgrade pip
pip install -e ".[dev,test]"
- name: Run migrations
run: alembic upgrade head
- name: Run tests with coverage
run: |
pytest tests/ \
--cov=myapp \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=80 \
-v
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
Matrix Testing Across Python Versions
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't cancel others if one fails
matrix:
python-version: ["3.11", "3.12", "3.13"]
os: [ubuntu-latest, windows-latest, macos-latest]
exclude:
# Skip Windows + Python 3.13 (not yet stable)
- os: windows-latest
python-version: "3.13"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Run tests
run: pytest tests/ -x --tb=short
Dependency Caching
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip" # built-in pip cache (simple projects)
# OR: manual cache for Poetry
- name: Cache Poetry virtualenv
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('poetry.lock') }}
restore-keys: |
venv-${{ runner.os }}-
- name: Install with Poetry
run: |
pip install poetry
poetry config virtualenvs.in-project true
poetry install --no-root
# Cache pre-commit hooks
- name: Cache pre-commit
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: precommit-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
run: pre-commit run --all-files
Linting and Code Quality
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install tools
run: pip install ruff mypy
- name: Ruff lint
run: ruff check --output-format=github .
- name: Ruff format check
run: ruff format --check .
- name: Type check with mypy
run: mypy myapp/ --ignore-missing-imports --strict
- name: Check for security issues
run: |
pip install bandit safety
bandit -r myapp/ -ll
safety check --full-report
Docker Build and Push
jobs:
docker:
runs-on: ubuntu-latest
needs: [test, quality] # only build if tests pass
if: github.ref == 'refs/heads/main'
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.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# OR: login to GitHub Container Registry
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # auto-provided
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
myorg/myapp
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=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 # GitHub Actions cache
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
PyPI Publishing
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi # requires manual approval
permissions:
id-token: write # required for trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
# Trusted publishing — no API token needed
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Deployment to AWS ECS
deploy:
needs: docker
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Download task definition
run: |
aws ecs describe-task-definition --task-definition myapp \
--query taskDefinition > task-definition.json
- name: Update ECS task definition image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: myapp
image: ${{ steps.meta.outputs.tags }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: myapp-service
cluster: production
wait-for-service-stability: true # wait for old tasks to stop
Managing Secrets
# Repository secrets: Settings → Secrets → Actions
# Reference in workflow:
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
# Environment secrets (require approval gate)
jobs:
deploy:
environment: production # uses secrets from 'production' environment
steps:
- run: deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
# GITHUB_TOKEN — auto-generated, no setup needed
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*.whl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Dynamic secrets from AWS Secrets Manager
- name: Get secrets from AWS
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
prod/myapp/database
prod/myapp/api-keys
parse-json-secrets: true
Frequently Asked Questions
- How do I run only changed tests (test selection)?
- Use
pytest-testmonwhich tracks which tests are affected by code changes and only runs those. Install it and runpytest --testmon. On CI, store the.testmondatafile in the Actions cache for persistence between runs. - How do I prevent deploys if tests fail?
- Use
needs: [test, quality]in your deploy job. GitHub Actions only runs a job if all itsneedsjobs succeed. Combine with branch protection rules to require the CI workflow to pass before merging pull requests. - How do I speed up slow GitHub Actions?
- Cache everything: pip packages, Poetry virtualenvs, pre-commit hooks, Docker layers (use
cache-from: type=gha). Split slow test suites withpytest-splitacross parallel jobs. Use larger runners (4-core) for CPU-intensive builds. Avoid running duplicate jobs — use workflow concurrency groups to cancel stale runs.