Python CI/CD with GitHub Actions
To add CI to a Python project, create .github/workflows/ci.yml, install the project and its development dependencies, then run Ruff, mypy, pytest with coverage, and a package build. The working workflow below tests Python 3.11, 3.12, and 3.13, enables pip caching, uses minimal permissions, and stores the built distributions as an artifact. Adapt the package name and supported versions, then commit it.
That gives every push and pull request an objective gate before merge. Style, types, behavior, and packaging are checked in a clean runner. Releasing to production remains a separate, optional decision.
CI, continuous delivery, and continuous deployment
Continuous integration (CI) means integrating small changes frequently and validating them automatically. For Python, the useful baseline is linting, formatting checks, tests, coverage, static typing, and packaging. CI catches integration defects while the change is still small and its context is fresh.
The initials CD are used for two related practices:
- Continuous delivery keeps every accepted revision ready to release, while a person or environment approval controls the release.
- Continuous deployment automatically releases every revision that clears the required gates.
CI/CD therefore does not require automatic production deployment. A team can use CI plus continuous delivery and retain a manual production approval. Container-based applications can extend this process with Python and Docker. Library maintainers can follow the guide to publishing a Python package to PyPI.
Understanding .github/workflows
GitHub loads YAML files from .github/workflows/. A file describes a workflow: on selects events, permissions constrains its token, and jobs contains independent units that run on fresh runners. Jobs contain ordered steps. The official GitHub Actions documentation is the source of truth for syntax, events, and runner behavior.
A conventional repository layout looks like this:
my-project/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── ci.yml
├── src/my_package/
├── tests/
├── pyproject.toml
└── README.md
Do not force CI, releases, and deployments into one oversized file. They need different events and permissions. Keeping ci.yml separate from release.yml makes security review easier and prevents an ordinary test job from inheriting deployment credentials.
Keep pyproject.toml and CI consistent
Every command invoked by CI must come from an installed dependency. This example defines one dev extra for local and hosted use:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-package"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = [
"build>=1.2,<2",
"mypy>=1.10,<2",
"pytest>=8,<9",
"pytest-cov>=5,<7",
"ruff>=0.5,<1",
]
[tool.pytest.ini_options]
addopts = "--strict-markers"
testpaths = ["tests"]
[tool.coverage.run]
source = ["my_package"]
[tool.coverage.report]
fail_under = 90
show_missing = true
[tool.mypy]
python_version = "3.11"
strict = true
[tool.ruff]
target-version = "py311"
line-length = 100
Version ranges allow controlled upgrades without accepting every future major release. Applications may prefer a lockfile; the trade-offs are covered in Python dependency management with pip. Whatever strategy you choose, developers and CI should use the same installation and validation commands.
A complete Python workflow
Create .github/workflows/ci.yml with the following content:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install project
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Ruff
run: |
ruff check .
ruff format --check .
- name: Mypy
run: mypy src
- name: Tests and coverage
run: pytest --cov=my_package --cov-report=term-missing --cov-report=xml
- name: Build distributions
if: matrix.python-version == '3.13'
run: python -m build
- name: Upload distributions
if: matrix.python-version == '3.13'
uses: actions/upload-artifact@v4
with:
name: python-distributions
path: dist/
if-no-files-found: error
retention-days: 7
checkout@v4 fetches the revision and setup-python@v5 provisions each interpreter. Its pip cache stores downloaded package data, not an entire virtual environment. The dependency path participates in the cache key, so changing pyproject.toml produces an appropriate cache. With fail-fast: false, every matrix entry finishes and reports whether a defect is version-specific.
Ruff checks lint rules and formatting according to its official documentation. It enforces decisions related to the complete PEP 8 guide without turning review into whitespace debate. Mypy verifies type contracts; pair the mypy documentation with this introduction to Python type hints.
Pytest prints missing lines and writes coverage.xml. The fail_under = 90 setting makes insufficient coverage fail the run. Coverage is evidence of execution, not proof of useful assertions, so design meaningful cases with the pytest automated testing guide.
Building only on Python 3.13 avoids producing the same pure-Python wheel three times. python -m build creates the wheel and source distribution in dist/; upload-artifact@v4 preserves them for seven days. A later release workflow can download a reviewed artifact instead of silently rebuilding different bytes.
Treat that artifact as the handoff between CI and delivery. Give it an unambiguous name, keep retention long enough for your review process, and never include .env files, credentials, test databases, or unrelated workspace contents. For a compiled extension, one wheel may not cover every Python version and platform; use an explicit build matrix appropriate to the package instead of copying the pure-Python optimization above. Before publishing, the release job can inspect dist/, verify the expected version, and upload those same files. This “build once, promote later” model makes an approval meaningful because reviewers know which output will reach users.
Artifacts and caches solve different problems. An artifact is an output intended for people or later jobs and has defined retention. A cache is an expendable performance aid that may disappear at any time. Never rely on a cache as the only copy of a release candidate.
Minimal permissions, secrets, and OIDC
permissions: contents: read applies least privilege to the workflow's GITHUB_TOKEN. A validation workflow has no reason to write repository content. Avoid write-all; grant a release job only the individual capabilities it needs.
Repository or environment secrets are appropriate for unavoidable stored tokens. Never print them, place them in command-line arguments exposed by process listings, or provide them to untrusted pull-request code. Fork pull requests and Dependabot have deliberate secret restrictions; bypassing those controls creates an exfiltration path.
For compatible cloud and publishing services, prefer OpenID Connect (OIDC). A deployment job receives id-token: write and exchanges its GitHub identity for a short-lived provider credential. There is no long-lived cloud key to rotate or leak. Restrict the provider trust policy by repository, branch, tag, or environment, following GitHub's official guide to deployment security with OIDC. Add id-token: write only to the deployment job, never to this CI job.
GitHub Environments can require reviewers, restrict deployment branches, and scope secrets. This supports continuous delivery: CI creates a tested artifact, while production waits for approval. Continuous deployment is optional and should come only with reliable tests, monitoring, and rollback.
Branch protection and required checks
A green workflow is advisory unless repository rules enforce it. Under Settings > Rules > Rulesets (or branch protection), require pull requests, approvals, and the Python 3.11, Python 3.12, and Python 3.13 checks. A dedicated stable summary job is another option when the matrix changes often. Prevent merges with unresolved conversations and tightly control bypass access.
Required checks are identified by name. Rename a required job without updating the rule and a pull request may wait forever for a check that cannot arrive. Automation also cannot judge architecture or naming in context; combine it with the principles in Python Clean Code best practices.
Dependabot and a status badge
Keep both action references and Python dependencies visible to your maintenance process with .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
Review release notes and let CI validate each pull request. Automatic updates do not justify automatic merging without policy. Add this badge to README.md, replacing the placeholders:
[](https://github.com/OWNER/REPO/actions/workflows/ci.yml)
A badge reports workflow status; it is not a security or quality certification.
Optimize without making CI brittle
Start with correctness, then inspect step timings. The setup-python pip cache removes repeated downloads. Point cache-dependency-path at the real manifest or lockfile, and treat every cache as disposable: a cold run must remain correct.
The concurrency group cancels obsolete runs for the same ref when a newer commit arrives. timeout-minutes limits hung tests. Build once, but keep behavior tests across every supported interpreter. In a large repository, linting and typing may move to one non-matrix job while tests retain the full matrix.
Caching .venv is usually a poor default because absolute paths, native binaries, Python versions, and runner images make it fragile. Do not use continue-on-error on a required control merely to obtain a green badge. Optimization should remove duplicate work, not suppress signals.
Common failure modes
- Wrong path: GitHub recognizes workflows only inside
.github/workflows/. - Missing tools: calling Ruff or mypy without installing the
devextra ends with a missing command. - Import errors: install the package with
pip install -e ".[dev]"instead of patchingPYTHONPATH. - Stale cache assumptions: list the actual dependency manifest in
cache-dependency-path. - Misleading coverage: a high percentage can still miss edge cases, error paths, and user behavior.
- Inaccurate matrix: align matrix versions with
requires-pythonand the documented support policy. - Excess permissions: write tokens or secrets in pull-request code increase supply-chain impact.
- Deployment in the CI job: production credentials then share events and scope with untrusted validation.
Production checklist
- [ ]
pyproject.tomldeclares supported Python versions and a completedevextra. - [ ] Ruff, mypy, pytest, and build use the same commands locally and in CI.
- [ ] The matrix covers every officially supported interpreter.
- [ ] Coverage has a useful threshold and tests assert behavior, not merely execution.
- [ ] The workflow has read-only contents, a timeout, and concurrency cancellation.
- [ ] Built distributions are artifacts with an explicit retention period.
- [ ] Required checks and reviews protect the default branch.
- [ ] Dependabot proposes pip and GitHub Actions updates for review.
- [ ] Secrets are minimal and compatible deployments use OIDC.
- [ ] Release and deployment live in a separate workflow with environment approval when needed.
This baseline makes integration feedback reproducible, delivery artifacts traceable, and deployment a conscious choice. The best pipeline is not the one with the most steps; it is the smallest one that blocks meaningful defects, limits authority, and remains clear to the people maintaining it.