Skip to content

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.

.github/workflows/ci.yml
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.

.github/workflows/deploy.yml
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.