GitHub Actions for Python, End-to-End¶
What this lesson gives you
A complete, production-shaped pipeline: lint, type-check, test across Python versions, build and push a Docker image, and deploy — all in GitHub Actions YAML you can read and modify confidently.
Estimated time: 20 min read · Part: CI/CD Foundations
Learning Objectives
After this lesson you will be able to: (1) read and write GitHub Actions YAML confidently; (2) explain triggers, jobs, steps, and reusable actions; (3) use a matrix strategy to test across multiple Python versions; (4) use needs: to sequence jobs; (5) tag Docker images with the commit SHA for traceability; (6) use OIDC to authenticate to a cloud provider without stored secrets.
GitHub Actions Structure¶
GitHub Actions defines pipelines as YAML files in .github/workflows/. A workflow file responds to one or more events (on:) and contains jobs. Each job runs on a runner (runs-on:) and consists of steps. A step is either a shell command (run:) or a reusable action (uses:). Actions are versioned building blocks — actions/checkout@v4 clones the repo; astral-sh/setup-uv@v3 installs uv. You pin actions to a version tag or SHA to prevent unexpected changes.
name: CI
on:
push:
branches: [main]
pull_request: # run on all PRs, any branch
jobs:
# ── Job 1: lint and type-check ───────────────────────────────────
quality:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.4.x"
- name: Set up Python
run: uv python install 3.12
- name: Install dev dependencies
run: uv sync --frozen --all-extras
- name: Lint
run: uv run ruff check . && uv run ruff format --check .
- name: Type check
run: uv run mypy src/
# ── Job 2: test across Python versions ──────────────────────────
test:
name: Test (Python ${{ matrix.python-version }})
needs: quality # only run if quality job passes
runs-on: ubuntu-latest
strategy:
fail-fast: false # run all matrix combos even if one fails
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install deps
run: uv sync --frozen --all-extras
- name: Run tests
run: uv run pytest --cov=src --cov-report=xml
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.python-version }}
path: coverage.xml
# ── Job 3: build and push Docker image ──────────────────────────
build:
name: Build & Push Image
needs: test # only build if ALL test matrix jobs pass
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # permission to push to GHCR
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # auto-provisioned, no setup
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,format=short # tag with short commit SHA
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }} # only push on main
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha # use GitHub Actions cache for layers
cache-to: type=gha,mode=max
Reading the Key Pieces
on: declares triggers — both push to main and all pull_request events. The matrix strategy fans the test job out across three Python versions, so you know your code works on all supported versions; fail-fast: false lets all matrix variants complete even if one fails, giving you a full picture. needs: test creates a dependency: the build job only runs if all three test matrix variants succeed. outputs: passes the image tag from the build job to a subsequent deploy job. permissions: gives the job only the minimal access it needs — principle of least privilege in CI.
Deployment with OIDC¶
The modern pattern for deploying to cloud providers uses OIDC federation instead of stored credentials. GitHub Actions generates a short-lived, cryptographically signed identity token for each job; the cloud provider trusts this token and exchanges it for temporary credentials scoped to the job's permissions. No long-lived cloud keys are stored in GitHub secrets — they cannot leak because they don't exist.
name: Deploy to Production
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
permissions:
id-token: write # required for OIDC token generation
contents: read
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
environment: production # requires manual approval in GitHub settings
steps:
- uses: actions/checkout@v4
# Keyless authentication: exchange OIDC token for temporary AWS creds
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-west-1
# No ACCESS_KEY_ID or SECRET_ACCESS_KEY needed
- name: Deploy to ECS
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
aws ecs update-service \
--cluster production \
--service payments-api \
--force-new-deployment \
--task-definition payments-api:latest
- name: Wait for deployment to stabilize
run: |
aws ecs wait services-stable \
--cluster production \
--services payments-api
| Feature | What it does | Why it matters |
|---|---|---|
on: pull_request | Run on every PR, not just merges | Catch failures before they reach main |
matrix: | Fan out one job across multiple configs | Test Python 3.11/3.12/3.13 simultaneously |
needs: | Explicit job dependency | Sequence as quality gates; fail fast |
environment: | Named deployment target with protection rules | Require manual approval for production |
permissions: | Restrict GitHub token scope per job | Principle of least privilege |
OIDC id-token: write | Allow OIDC token generation | Keyless cloud authentication |
cache-from: type=gha | Cache Docker layers in GitHub Actions cache | Dramatically faster image builds on repeat runs |
| Commit SHA tag | Tag image with ${{ github.sha }} | Immutable, traceable image identifier |
Consulting Lens: Reading, Not Memorizing
In consulting, you won't be asked to memorize GitHub Actions syntax. You will be asked to read a client's pipeline, reason about it, and identify improvements. When you see a pipeline, ask: Is the fail-fast gate in place? Are secrets managed correctly? Is the image tagged immutably? Is the deployment gated on tests? Is OIDC used or are there long-lived keys? The answers to these five questions reveal the pipeline's security and reliability posture — and each gap is a concrete recommendation. Being able to do this assessment with any CI/CD tool (GitLab CI YAML, a Jenkinsfile, an Azure Pipelines YAML) is the actual skill.
Knowledge check
InterviewIn a CI workflow, what does declaring needs: test on the build job accomplish?
- It runs the build and test jobs simultaneously for speed.
- It makes the build job depend on the test job — the build only runs if the tests pass, enforcing that broken code is never built or deployed.
- It deletes the test job after it completes.
- It is a comment with no runtime effect.
Answer
It makes the build job depend on the test job — the build only runs if the tests pass, enforcing that broken code is never built or deployed.
needs declares an explicit dependency between jobs. With needs: test , the build job waits for the test job (including every matrix variant) and runs only if it succeeded. This sequences the pipeline as a quality gate — code that fails tests never reaches the build or deploy stages. This is the essence of CI: every commit is verified before it can proceed.
Knowledge check
PracticalWhy tag Docker images with the commit SHA rather than a label like "latest" or a date?
- The commit SHA is shorter than a date and saves storage.
- The commit SHA is immutable and directly traces the image to the exact source code that built it — enabling precise rollback, audit, and reproduction of any deployed version.
- Container registries require SHA tags; other tags are rejected.
- SHA tags are encrypted; date tags are public.
Answer
The commit SHA is immutable and directly traces the image to the exact source code that built it — enabling precise rollback, audit, and reproduction of any deployed version.
A commit SHA is an immutable identifier: it uniquely identifies a specific state of the source code. An image tagged with a SHA can be traced back to its exact source, tested deterministically, and redeployed identically. "Latest" is mutable — it points to whatever was pushed most recently, making rollback and auditing unreliable. A date is human-readable but also mutable (you can push multiple images in a day). The SHA is the only immutable, traceable identifier in the Git/Docker pipeline.
Exercise
Write a GitHub Actions workflow that: (1) triggers on push to main and on pull requests; (2) has a lint job that runs ruff check and mypy; (3) has a test job that depends on lint, runs pytest on Python 3.12, and uploads a coverage artifact; (4) has a notify job that depends on test and logs "All checks passed, ready to deploy" — but only runs on the main branch.
Show solution
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen
- run: uv run ruff check . && uv run ruff format --check .
- run: uv run mypy src/
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv python install 3.12
- run: uv sync --frozen
- run: uv run pytest --cov=src --cov-report=xml
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
notify:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- run: echo "All checks passed, ready to deploy"
Key Takeaways
- GitHub Actions = YAML in
.github/workflows/: events → jobs → steps/actions. - A matrix strategy fans one job across multiple Python versions for compatibility testing.
needs:sequences jobs into quality gates; the build only runs if tests pass.- Tag images with the commit SHA for immutable, traceable deployment artifacts.
- Use OIDC federation for keyless cloud authentication — no long-lived secrets stored.
- Understanding what a pipeline does is more important than memorizing syntax — the concepts transfer across tools.