You pushed a quick fix on Friday, all green on your laptop, and by Monday the app was down because you forgot to run the tests. GitHub Actions exists so that story never happens again. It runs your linting, type checking, and tests automatically on every push and pull request, then deploys only when everything passes. This guide builds a production-ready CI/CD (Continuous Integration and Continuous Deployment) pipeline step by step, plus pre-commit hooks and a Docker deploy job.
“If it hurts, do it more often.”
Martin Fowler, on Continuous Integration
Last Updated: July 2026 | Tested on: Python 3.14.6, Ruff 0.15.18, mypy 2.1.0, pytest 9.1.1 | Difficulty: Advanced | Reading Time: 13 minutes
Think of CI/CD like the security checkpoint at an airport. Continuous Integration (CI) is the scanner: every time code tries to get in, it runs your tests automatically and waves through only the clean stuff. Continuous Deployment (CD) is the boarding gate at the far end: once code clears the checkpoint, it goes straight to production with no manual gate-keeping. Put the two together and the whole “I forgot to run the tests before merging” class of bugs simply stops reaching your users.
GitHub Actions is a CI/CD platform baked right into GitHub. You write a short YAML file, commit it to your repository, and GitHub runs your pipeline on every push. No separate CI server, no Jenkins box to babysit, no third-party account to wire up. The same pipeline can lint your code, run type checking, execute tests, build a Docker image, and ship to production, all triggered by one git push.
A developer I worked with, Vinay, set up his first pipeline in about 15 minutes. On his very next pull request it flagged a bug he was one click away from merging. The pipeline paid for itself before lunch.
The diagram walks through a GitHub Actions CI/CD pipeline. A git push or pull request triggers it, then it runs lint checks with Ruff, type checking with mypy, the test suite with coverage, builds a Docker image, and deploys only if every step before it passed. Each step is a job that runs in a fresh, isolated container, and a failure at any stage stops the pipeline cold. This automated gate catches bugs, style slips, and type errors before code ever reaches production. The earlier a problem is caught, the cheaper it is to fix.
Table of Contents
Prerequisites
You need a GitHub repository and Git installed locally. Familiarity with the pytest tutorial, the code quality tutorial (Ruff and mypy), and the Docker tutorial is helpful, since your pipeline runs exactly those tools.
Run the Checks Locally First (Quick Win)
Here is the secret that makes CI feel easy: your pipeline does nothing magical. It just runs the same commands you can run on your own machine, the way a dishwasher runs the same scrub-rinse-dry routine you would do by hand at the sink, just automatically and every single time. So before you write a single line of YAML, prove the idea with a tiny project. Three files: one module, one test, and you run the exact tools the pipeline will run.
📄 src/calculator.py: the code your pipeline will check
"""A tiny module the CI pipeline will check."""
def add(a: int, b: int) -> int:
return a + b
def divide(a: int, b: int) -> float:
if b == 0:
raise ValueError("cannot divide by zero")
return a / b
📄 tests/test_calculator.py: the tests pytest will run
import pytest
from src.calculator import add, divide
def test_add() -> None:
assert add(2, 3) == 5
def test_divide() -> None:
assert divide(10, 4) == 2.5
def test_divide_by_zero() -> None:
with pytest.raises(ValueError):
divide(1, 0)
Now run the three checks your pipeline will automate. These are the real commands, and the output below is exactly what they printed on Python 3.14.6.
📄 Terminal: the three checks, run by hand
ruff check . mypy src/ python -m pytest -v
▶ Output
All checks passed! Success: no issues found in 1 source file ============================= test session starts ============================= platform win32 -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 -- C:\tmp\demo\Scripts\python.exe cachedir: .pytest_cache rootdir: C:\tmp\demo collected 3 items tests/test_calculator.py::test_add PASSED [ 33%] tests/test_calculator.py::test_divide PASSED [ 66%] tests/test_calculator.py::test_divide_by_zero PASSED [100%] ============================== 3 passed in 0.09s ==============================
What happened here: Ruff scanned every file and found nothing to complain about. mypy checked the type hints and agreed the code is consistent. Running the tests as python -m pytest (rather than bare pytest) puts your project root on Python’s import path, so the tests can find src; all three passed. That is the entire pipeline in three commands. Everything from here is about getting GitHub to run these same commands for you, automatically, on every push so you never have to remember.
Why does this matter so much? Because the moment you commit a mistake, the pipeline runs the same check and stops you. Drop two unused imports into a file and Ruff catches it instantly.
▶ Output: Ruff catching a real mistake (exit code 1, so CI fails)
F401 [*] `os` imported but unused --> src/bad_example.py:1:8 | 1 | import os | ^^ 2 | import sys | help: Remove unused import: `os` F401 [*] `sys` imported but unused --> src/bad_example.py:2:8 | 1 | import os 2 | import sys | ^^^ | help: Remove unused import: `sys` Found 2 errors. [*] 2 fixable with the `--fix` option.
What happened here: Ruff exited with code 1, and a non-zero exit code is how every CI step says “I failed.” GitHub Actions watches that exit code: zero means pass, anything else means stop the pipeline and block the merge. Notice Ruff even tells you it can auto-fix both lines with --fix. (The path separator in the message can print as / or \ depending on the operating system; the check itself behaves exactly the same locally and on the CI runner.)
Your First GitHub Actions Workflow
A workflow lives in a file under .github/workflows/ in your repository. Commit it, push, and GitHub starts running it. Here is a complete pipeline that runs the same three checks you just ran by hand, across several Python versions at once.
📄 .github/workflows/ci.yml: a Python CI pipeline
name: Python CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
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: ruff check .
- name: Type check with mypy
run: mypy src/
- name: Run tests with pytest
run: python -m pytest --cov=src --cov-report=xml -v
- name: Upload coverage
if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v4
with:
files: coverage.xml
What happened here: The on: block says when to run, that is, on every push to main or develop and on every pull request targeting main. The matrix tells GitHub to run the whole job three times in parallel, once each on Python 3.12, 3.13, and 3.14, so you catch version-specific breakage before your users do. Each run: step is a shell command, the same ruff check ., mypy src/, and pytest from the Quick Win. If any step exits non-zero, the job fails and the pull request is blocked. The coverage upload only runs once, on 3.14, so you do not upload the same report three times.
Think of the matrix like testing a cake recipe in three different ovens at the same time. If the cake only burns in one of them, you find out today, not after a customer complains.
Pre-commit Hooks: Catch Issues Before Push
CI catches problems after you push. Pre-commit hooks catch them before you even commit, so you never push broken code in the first place. A hook is just a script Git runs automatically at the moment you type git commit. If the script fails, the commit is cancelled until you fix things. It is the spell-checker that runs before you hit send, not after.
📄 .pre-commit-config.yaml: run checks on every commit
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.18
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
📄 Terminal: install and activate pre-commit
pip install pre-commit pre-commit install # Activates the hooks for this repo pre-commit run --all-files # Run every hook on every file once
What happened here: pre-commit install wires the hooks into Git’s commit step for this repository. From then on, every git commit runs Ruff (with auto-fix), Ruff’s formatter, and a few small housekeeping checks that strip trailing whitespace and validate your YAML. If Ruff finds a lint error it cannot fix automatically, the commit is blocked until you sort it out. Pin the rev: values so every teammate runs the identical hook versions. The two pins above, Ruff 0.15.18 and pre-commit-hooks 6.0.0, are the current releases at the time of writing.
Deployment Job: Docker Build and Push
Testing is half the job. The other half is shipping. Add a deploy job that builds a Docker image and pushes it, but only after the tests pass and only on the main branch.
📄 .github/workflows/deploy.yml: build and push a Docker image
name: Deploy
on:
push:
branches: [main]
jobs:
test:
# ... same test job as the ci.yml above ...
deploy:
needs: test # Only deploy if the test job passed
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t my-app:latest .
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to Docker Hub
run: |
docker tag my-app:latest myuser/my-app:latest
docker push myuser/my-app:latest
One thing before you copy this file: the # ... same test job as the ci.yml above ... line is a placeholder, not valid YAML. Paste the full test: job from the ci.yml earlier in this post where that comment sits, otherwise GitHub Actions rejects the workflow as invalid.
What happened here: The needs: test line is the safety gate. It tells GitHub to start the deploy job only after the test job finishes green, so you can never ship code that failed its tests. The if: github.ref == 'refs/heads/main' condition makes sure deploys happen only from main, not from feature branches. The login credentials come from secrets.DOCKER_USERNAME and secrets.DOCKER_PASSWORD, which you store in the repository’s Settings under Secrets, never in the code itself. A secret in your repo is like a hotel safe: GitHub keeps it locked away and only hands it to the running job, and it is masked out of the logs.
Common Mistakes
Mistake 1: Hard-coding secrets in the workflow
🚫 Wrong
- name: Login to Docker Hub run: docker login -u myuser -p hunter2_real_password # leaked forever
✅ Correct
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Why: Anything you paste into a workflow file is committed to Git history, visible to anyone with repo access, and printed in build logs. Once a password lands in Git history it is effectively public, so use secrets. for every credential.
Mistake 2: Skipping the needs gate before deploy
🚫 Wrong
deploy: runs-on: ubuntu-latest # no needs:, so it deploys even if tests fail
✅ Correct
deploy: needs: test # deploy waits for a green test job runs-on: ubuntu-latest
Why: Without needs: test, GitHub runs your jobs in parallel and the deploy can finish while the tests are still failing. That is the exact bug CI/CD is supposed to prevent. The needs key turns parallel jobs into an ordered chain.
Conclusion
You now have the full CI/CD loop for a Python project: pre-commit hooks that catch mistakes before a commit exists, a GitHub Actions workflow that runs Ruff, mypy, and pytest across three Python versions on every push, and a deploy job that ships a Docker image only when everything before it is green. The two rules worth tattooing on your workflow files: every credential lives in secrets., and every deploy job carries a needs: gate.
Next up, we go deeper into the tools this pipeline runs, with Ruff rules, mypy strictness levels, and pre-commit configuration in the code quality tutorial. For every other topic in this series, from beginner basics to AI/ML projects, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is GitHub Actions for Python?
GitHub Actions is a CI/CD platform built into GitHub that runs workflows (YAML files) automatically on git events like push, pull request, or a schedule. For Python projects it runs your Ruff linting, mypy type checking, and pytest suite on every push. It is free for public repositories and gives generous free minutes for private repos.
How much does GitHub Actions cost?
It is free for public repositories. Private repos get 2,000 free minutes per month on the free plan. Linux runners are the cheapest, while macOS runners cost about 10 times more per minute. Most Python projects stay comfortably inside the free tier.
What is the difference between CI and CD?
CI (Continuous Integration) runs your tests automatically on every code change. CD (Continuous Deployment) automatically deploys when those tests pass. CI catches bugs early, and CD removes the manual, error-prone deployment step.
Should I test on multiple Python versions?
Yes if your code is a library that other people install, since their interpreter version is out of your control. Use a matrix strategy across 3.12, 3.13, and 3.14. For an internal application where you control the runtime, testing on your production version alone is usually enough.
What is the difference between pre-commit hooks and GitHub Actions?
Pre-commit hooks run on your own machine before a commit is created, so problems are caught before code ever leaves your laptop. GitHub Actions runs on GitHub’s servers after you push. Use both: pre-commit for fast local feedback, and CI as the gate that no one can bypass.
Try It Yourself
Add CI/CD to one of your GitHub repositories. Create a ci.yml workflow that runs Ruff linting, mypy type checking, and pytest with coverage on every push. Add a .pre-commit-config.yaml too. Then open a pull request that contains a deliberate linting error, like an unused import, and watch the pipeline turn red and block the merge. Fixing it and seeing the checks go green is the moment CI/CD finally clicks.
Interview Questions on CI/CD with GitHub Actions
Try each one aloud first. The phrasing you produce under mild pressure is what interviews measure.
Q: How does GitHub Actions know whether a step passed or failed?
It watches the exit code of each run: command. Zero means success, anything non-zero fails the step, which fails the job, which can block the pull request. That is why tools like Ruff, mypy, and pytest work in CI with no special integration: they already exit non-zero when they find problems. It also means any script you write for CI must propagate failures instead of swallowing them.
Q: What is the difference between needs: and if: on a job?
needs: controls ordering and dependency: the job waits for the listed jobs and, by default, only runs if they succeeded. if: is a condition evaluated on the event context, such as github.ref == 'refs/heads/main' to restrict a job to the main branch. A production deploy job typically uses both: needs: test so it never ships failing code, and an if: check so feature branches never deploy.
Q: Your test job is green on every pull request, but the deploy job on main fails with “Username and password required” from docker/login-action. What do you check first?
That error means secrets.DOCKER_USERNAME or secrets.DOCKER_PASSWORD resolved to empty. First check that both secrets actually exist in the repository’s Settings, with names that match the workflow exactly, since secret names are case-sensitive. Then check whether the secrets were created in a GitHub Environment: if so, the job must declare environment: to receive them. Also remember that workflows triggered by pull requests from forks do not get repository secrets at all, which is why secret-dependent jobs belong on push-to-main triggers.
Q: Your pipeline used to finish in 3 minutes and now takes 12, with most of the time spent in pip install. How do you speed it up?
Cache the dependencies. The simplest fix is adding cache: 'pip' to the actions/setup-python step, which caches downloaded packages keyed on your requirements files, so unchanged dependencies install from cache instead of the network. For finer control, use actions/cache with a key built from hashFiles('requirements*.txt'). Also prune the dev requirements file: CI only needs the tools it actually runs.
Q: In a matrix build, one Python version fails and the other two get cancelled. Why, and how do you change that?
That is the matrix’s fail-fast behavior, which defaults to true: as soon as one matrix job fails, GitHub cancels its siblings to save runner minutes. Set fail-fast: false under strategy: when you want the full picture, for example to learn whether a failure is specific to Python 3.12 or breaks every version. Fail-fast saves money; fail-slow saves debugging time. Pick per workflow.
Q: Why do workflows pin actions to a version, like actions/checkout@v4, instead of using the latest?
Two reasons: reproducibility and supply-chain security. An unpinned or loosely pinned action can change under you, breaking builds or, worse, shipping malicious code into a job that holds your deploy secrets. Pinning to a major version tag like @v4 is the common balance, while security-sensitive teams pin to a full commit SHA, which cannot be moved or re-tagged. Treat third-party actions like any other dependency: pin them and review upgrades deliberately.
Go deeper: when you outgrow this post, Official Git documentation is the next stop.
Related Posts
Previous: Python: Code Quality with Ruff, mypy, Pre-commit Hooks
Next: AI-Assisted Coding: Cursor, Claude Code, Copilot Workflow
Series Home: Python + AI/ML Tutorial Series

No comment