Module 7: CI Integration with GitHub Actions

GitHub Actions Fundamentals

GitHub Actions Fundamentals

Capsule overview

You have tests that pass on your machine. But what happens when another developer pushes, when you merge a PR, or when you simply forget to run pytest before the commit? Without automation, tests are a promise that breaks under pressure.

CI (Continuous Integration) means that every change — every push, every pull request — automatically triggers the test run. If something fails, the system tells you before the code reaches production. It's the safety net that turns your tests into something that really matters.

This capsule introduces you to GitHub Actions, the CI system built into GitHub. You'll learn what a workflow, a job, a step and a runner are; how to write valid YAML; how to configure triggers; and you'll have a complete workflow that does checkout → setup Python → install deps → run pytest from the very first line. By the end you'll know how to ask Claude Code to generate workflows based on your project.


What CI is and why it matters

A practical definition

Continuous Integration is the practice of integrating code into a shared repository frequently, and running a set of automatic checks (tests, lint, build) on every integration. The goal: detecting problems as soon as they appear, when they're cheap to fix.

The problems CI solves

  • Tests nobody runs: If the tests are manual, they get skipped under deadlines. CI always runs them.
  • "It works on my machine": CI runs in a clean, reproducible environment. If it passes there, it's more likely to pass in production.
  • Merging broken code: Without CI, someone can merge a PR that breaks tests without knowing it. With CI, the PR is blocked until everything passes.
  • Confidence for refactoring: If you have CI, you can refactor safely. The tests validate that you didn't break anything.

Why it matters with Claude Code

Claude Code generates code fast. It can produce dozens of files in minutes. Without CI, you depend on you (or someone) remembering to run pytest. With CI, every push — whether human or AI — triggers the tests. If Claude Code generated something that breaks an existing test, you know in seconds. It's the natural complement to the TDD you practiced in previous modules.


GitHub Actions concepts

Workflow

A workflow is an automated process defined in a YAML file. It lives in .github/workflows/. A repository can have multiple workflows (tests, deploy, lint, etc.). Each workflow is defined in a separate file.

Job

A job is a set of steps that run on the same runner. Jobs can be sequential (one after another) or parallel (depending on their dependencies). By default, jobs in the same workflow run in parallel. Each job runs on a clean runner — they don't share the filesystem between jobs unless you use artifacts.

Step

A step is an atomic unit of work within a job. It can be a script you write or a predefined action (like actions/checkout@v4). Steps run in order. If a step fails, the job fails and the following steps don't run.

Runner

A runner is the machine that runs the jobs. GitHub provides hosted runners (Ubuntu, Windows, macOS). Each job runs on a fresh virtual machine, with a clean operating system. That's why installing dependencies is necessary in every job.

A mental diagram

Workflow (a .yml file)
└── Job: test
    ├── Step 1: check out the code
    ├── Step 2: set up Python
    ├── Step 3: install deps
    └── Step 4: run pytest

YAML syntax for workflows

The basic structure

name: Workflow name

on:
  push:
  pull_request:

jobs:
  job-id:
    runs-on: ubuntu-latest
    steps:
      - uses: action/name@version
      - run: command
  • name: A readable name that appears in GitHub's UI.
  • on: The events that trigger the workflow (push, pull_request, workflow_dispatch, etc.).
  • jobs: A dictionary of jobs. Each job has a runs-on (the runner) and a list of steps.

Indentation

YAML is sensitive to indentation. Use spaces, never tabs. Two spaces per level is the standard. An indentation error invalidates the file.

Steps: uses vs run

  • uses: Runs a reusable action (from the GitHub Marketplace or your repo). Example: uses: actions/checkout@v4.
  • run: Runs a command in the shell. Example: run: pytest tests/. You can use run: | for multiline scripts.

A quick reference

SyntaxUse
on: pushTriggers on any push
on: pull_requestTriggers on any PR
on: workflow_dispatchAllows a manual run from the UI
runs-on: ubuntu-latestUses an Ubuntu runner
- uses: owner/repo@refRuns an action
- run: cmdRuns a command

A complete workflow from scratch

Here's a working workflow that does a checkout, configures Python, installs dependencies and runs pytest. Copy it as-is into .github/workflows/tests.yml in your repository.

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/ -v

A step-by-step explanation

1. Checkout: actions/checkout@v4 downloads the repository's code to the runner. Without this, you have no files.

2. Set up Python: actions/setup-python@v5 installs the specified Python version. Without this, you only have the system's Python (which may be old or nonexistent).

3. Install dependencies: We install pip and the dependencies from requirements.txt. The runner doesn't have your packages; they have to be installed.

4. Run tests: We run pytest. The -v (verbose) flag makes you see each test in the logs.

If you use pyproject.toml instead of requirements.txt

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

Or if you define development dependencies in [project.optional-dependencies]:

[project.optional-dependencies]
dev = ["pytest", "pytest-cov"]

Then pip install -e ".[dev]" installs the package in editable mode plus the development dependencies.

A project with neither requirements.txt nor pyproject.toml

If your project is minimal:

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pytest

Triggers: when the workflow runs

on: push

on:
  push:
    branches: [main, develop]

It runs when you push to main or develop. If you omit branches, it runs on any push to any branch.

on: pull_request

on:
  pull_request:
    branches: [main, develop]

It runs when a PR whose base branch is main or develop is opened or updated. It's the most useful trigger for CI: every PR triggers the tests before the merge.

on: workflow_dispatch

on:
  workflow_dispatch:

It allows you to run the workflow manually from GitHub's Actions tab. Useful for debugging or for workflows you don't want to be automatic.

Combining triggers

on:
  push:
    branches: [main]
  pull_request:
    branches: [main, develop]
  workflow_dispatch:

The workflow runs on a push to main, on PRs targeting main or develop, and manually.

Excluding files (avoiding CI on docs)

on:
  push:
    paths-ignore:
      - "**.md"
      - "docs/**"
  pull_request:
    paths-ignore:
      - "**.md"
      - "docs/**"

If you only changed .md files or files inside docs/, the workflow doesn't run. It saves minutes in repos with a lot of documentation.


The structure of a project with CI

Your repository could look like this:

my-project/
├── .github/
│   └── workflows/
│       └── tests.yml
├── src/
│   └── my_module/
│       └── __init__.py
├── tests/
│   └── test_my_module.py
├── requirements.txt
└── pyproject.toml

The .github/workflows/tests.yml file is all GitHub needs to run your CI. When you push, GitHub detects the workflow and triggers it.


Verifying that it works

After creating the workflow and pushing:

  1. Go to your repository on GitHub.
  2. The Actions tab.
  3. You'll see the "Tests" workflow in the list. Click the most recent run.
  4. Click the "test" job to see the steps.
  5. Each step shows its output. If pytest passes, you'll see the green check.

If something fails, the logs of the failed step will show you the error (for example, a failing test, an import that doesn't exist, or a package that isn't in requirements.txt).


How to read GitHub Actions logs

When you open a workflow run, you see the list of jobs. Each job expands to show the steps. Each step has an icon: a green check if it passed, a red X if it failed.

The structure of a step that passes

✓ Checkout repository (2s)
✓ Set up Python (8s)
✓ Install dependencies (15s)
✓ Run tests (12s)

Click a step to see its complete output. In "Run tests" you'll see pytest's output as-is: test names, PASSED/FAILED, and the final summary.

When a step fails

The job stops at the step that failed. The following steps don't run. Click the failed step to see the error. Typical examples:

  • Test failure: You'll see pytest's traceback with the failing assert and the file/line.
  • pip install failed: You'll see which package couldn't be installed and pip's error.
  • ModuleNotFoundError: It appears in pytest's output; it indicates a missing import or that the path isn't configured.

Debugging tips

  1. Copy the complete error: From the first line of the traceback to the end. That context helps Claude Code (or you) diagnose it.
  2. Check the previous step: If "Run tests" fails with a ModuleNotFoundError, "Install dependencies" may not have installed everything. Review that step too.
  3. Run it locally: Reproduce the environment: python -m venv .venv, activate, pip install -r requirements.txt, pytest tests/. If it passes locally but fails in CI, look for differences (Python version, environment variables, paths).

Guided practice: your first workflow from scratch

Follow these steps to have your first workflow running in under 10 minutes.

1. Create the directory structure

mkdir -p my-ci-project/.github/workflows my-ci-project/tests
cd my-ci-project

2. Create a trivial test

# tests/test_hello.py
def test_hello():
    assert "hello" in "hello world"

3. Create requirements.txt

pytest>=7.0.0

4. Create the workflow

Create .github/workflows/tests.yml with the content of the complete workflow you saw above (checkout, setup Python 3.12, install, run pytest).

5. Initialize git and push

git init
git add .
git commit -m "Add CI workflow"
git remote add origin https://github.com/your-username/my-ci-project.git
git push -u origin main

6. Verify on GitHub

Open the repo on GitHub → Actions. You should see the workflow run. Wait for it to finish (about 30-60 seconds). A green check = success.

7. Test that it fails (optional)

Modify the test so it fails: assert False. Push. Go to Actions and observe that the workflow shows a red X. It's the confirmation that CI is protecting your main.

8. Revert and continue

Put the correct test back and push. The workflow should pass again.


Basic permissions and security

By default, workflows have limited permissions. You can restrict them explicitly:

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read   # Needed for checkout
      # actions: read  # To use actions
    steps:
      - uses: actions/checkout@v4
      # ...

For a tests-only workflow, contents: read is enough. If later you use actions that write to the repo (for example, commenting on PRs), you'll need additional permissions. For now, the default is usually enough.


Environment variables in the workflow

You can define variables for all of a job's steps:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      PYTHONUNBUFFERED: 1
      MY_VAR: value
    steps:
      - uses: actions/checkout@v4
      - run: echo $MY_VAR

PYTHONUNBUFFERED: 1 is useful for seeing pytest's output in real time in the logs. For secrets (API keys, tokens), use GitHub Secrets, don't put them in plain text. You'll see that in capsule 03.


Claude Code: a prompt to generate the workflow YAML

When you want Claude Code to generate or adapt a workflow for your project, give it clear context:

I have a Python project with this structure:

- src/ with the code
- tests/ with pytest
- requirements.txt with the dependencies (pytest, pytest-cov included)

Generate a .github/workflows/tests.yml file that:
1. Runs on a push to main and on a pull_request to main
2. Uses Python 3.12
3. Installs from requirements.txt
4. Runs pytest tests/ -v

The file must be runnable from the very first line.

Claude Code will generate the YAML. Review it, adjust the Python version or the paths if your project differs, and push. If it fails, paste the log's error into the chat and ask for the fix.

Useful variants

With pyproject.toml:

My project uses pyproject.toml, not requirements.txt. The test dependencies are in [project.optional-dependencies] dev = ["pytest"]. Generate the workflow using pip install -e ".[dev]".

With multiple Python versions:

Generate the workflow with a matrix for Python 3.10, 3.11 and 3.12.

Manual only:

I only want workflow_dispatch to run the tests by hand from the UI.

Guided practice: from zero to working CI

If you want to reproduce everything from scratch, follow these steps.

Step 1: The project structure

mkdir -p my-ci-project/.github/workflows my-ci-project/tests
cd my-ci-project

Step 2: Create a module and tests

# src/calc.py (also create an empty src/__init__.py)
def add(a: float, b: float) -> float:
    return a + b

def multiply(a: float, b: float) -> float:
    return a * b
# tests/test_calc.py
from src.calc import add, multiply

def test_add():
    assert add(2, 3) == 5

def test_multiply():
    assert multiply(3, 4) == 12

Step 3: requirements.txt

pytest>=7.0.0

Step 4: Create the workflow

Create .github/workflows/tests.yml with the content of the complete workflow you saw above (checkout, setup Python 3.12, install, pytest).

Step 5: Initialize git and push

git init
git add .
git commit -m "Add project with CI"
git remote add origin https://github.com/your-username/my-ci-project.git
git branch -M main
git push -u origin main

Step 6: Verify on GitHub

Open your repo on GitHub, the Actions tab. You should see a run of the "Tests" workflow with a green check. Go into the job and review each step's output. In "Run tests" you'll see pytest's output with both tests passing.


How to read the logs in GitHub's UI

When a workflow runs, GitHub gives you a hierarchical view:

  1. Workflow run: The complete run (one push = one run).
  2. Job: Each job appears as a block. If you have a single "test" job, you'll see a "test" block.
  3. Steps: Within the job, each step is expandable. Click "Run tests" to see pytest's output.

What to look for when something fails

  • The "Install dependencies" step fails: Check whether requirements.txt exists and whether all the packages are available on PyPI. If you use pip install -e ".[dev]", verify that pyproject.toml has the [project.optional-dependencies] section.
  • The "Run tests" step fails: pytest's output appears there. Look for FAILED tests/test_xxx.py::test_name. That's the test that failed. The traceback tells you the line and the error.
  • The job never runs: Check the triggers. If you configured branches: [main] and pushed to feature/foo, the workflow isn't triggered for that push (unless you have a PR to main).

Downloading the logs

On the workflow run's page, there's a "Download log archive" button. Useful for sharing logs with your team or with Claude Code for debugging.


Naming conventions

  • Workflow files: Use descriptive names: tests.yml, lint.yml, deploy.yml. Avoid generic names like ci.yml if you have several workflows.
  • Jobs: Use IDs in snake_case: test, lint, build. The job's name (if you set it) can be more readable: Run Tests.
  • Steps: A step's name helps a lot in the logs. Without a name, GitHub shows the command or the action, which can be hard to read.

An example:

steps:
  - name: Checkout repository
    uses: actions/checkout@v4
  - name: Set up Python 3.12
    uses: actions/setup-python@v5
    with:
      python-version: "3.12"

Environment variables and secrets (a preview)

Although you'll go deeper in capsule 03, it's useful to know that you can inject variables:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      MY_VAR: value
      API_URL: https://api.example.com
    steps:
      - run: echo $MY_VAR

For secrets (API keys, tokens): Settings → Secrets and variables → Actions → New repository secret. Then in the workflow:

env:
  API_KEY: ${{ secrets.API_KEY }}

Don't echo secrets in the logs; GitHub obfuscates them, but it's bad practice to expose them.


Exercises

Exercise 1: Create a basic workflow (Basic)

Create a repository (or use an existing one) with tests/test_example.py containing a trivial test def test_pass(): assert True. Add .github/workflows/tests.yml with a checkout, setup Python 3.11, install pytest, run pytest. Push and verify that the workflow passes.

See solution
# .github/workflows/tests.yml
name: Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - run: pip install pytest

      - run: pytest tests/ -v

tests/test_example.py:

def test_pass():
    assert True

The command: git add .github/workflows/tests.yml tests/test_example.py && git commit -m "Add CI" && git push. Then open the Actions tab on GitHub.

Exercise 2: Add workflow_dispatch (Basic)

Modify exercise 1's workflow so it can also be run manually. Run it once from the Actions UI and confirm that it runs.

See solution

Add workflow_dispatch to the trigger:

on:
  push:
  pull_request:
  workflow_dispatch:

Then: Actions → Tests → Run workflow → Run workflow.

Exercise 3: A trigger only on main (Intermediate)

Configure the workflow so it runs on push and PR only when the target branch is main. If your repo uses master, use master.

See solution
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Or for master:

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

Exercise 4: Ignore changes in the README (Intermediate)

Make the workflow not run when the only change is in README.md or in files inside docs/.

See solution
on:
  push:
    branches: [main]
    paths-ignore:
      - "README.md"
      - "docs/**"
  pull_request:
    branches: [main]
    paths-ignore:
      - "README.md"
      - "docs/**"

Exercise 5: A workflow with pyproject.toml (Intermediate)

Your project has pyproject.toml with [project.optional-dependencies] dev = ["pytest", "pytest-cov"]. Write the correct installation step so the workflow installs the package in editable mode with the development dependencies.

See solution
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

This installs the current package (from the directory where the job runs, which after checkout is the root) in editable mode, plus the dependencies listed in dev.

Exercise 6: A Claude Code prompt (Advanced)

Write a prompt for Claude Code that generates a workflow for a project with the structure app/ (code) and tests/, which uses pyproject.toml with dependencies in [project.dependencies] and [project.optional-dependencies] test = ["pytest"], and which runs pytest with -v --tb=short.

See solution
Generate .github/workflows/tests.yml for this Python project:

- The code is in app/
- The tests are in tests/
- pyproject.toml with [project.dependencies] and [project.optional-dependencies] test = ["pytest"]
- I want it to install with pip install -e ".[test]" and run pytest tests/ -v --tb=short
- Triggers: push and pull_request to main
- Python 3.12

Claude Code will generate something like:

name: Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e ".[test]"
      - run: pytest tests/ -v --tb=short

Troubleshooting

The workflow doesn't run when I push

Cause: The file may be in the wrong path or have the wrong extension.

Solution: The file must be .github/workflows/name.yml (or .yaml). Verify that the directory is named exactly workflows and that the file is in that directory. If you use paths-ignore or branches, make sure your push matches the configuration (e.g. a push to main if you configured branches: [main]).

Error: "pip: command not found"

Cause: The setup Python step didn't run correctly or you used pip before Python was available.

Solution: Use python -m pip instead of pip directly, or make sure the actions/setup-python@v5 step comes before the install step. Example: run: python -m pip install pytest.

Error: "ModuleNotFoundError: No module named 'X'"

Cause: The package isn't in requirements.txt (or in pyproject.toml's dependencies) or the installation step didn't install the test dependencies.

Solution: Add the package to requirements.txt or to the development optional dependencies. If you use pip install -e ., include the test deps: pip install -e ".[dev]" or .[test].

A YAML indentation error

Cause: Tabs instead of spaces, or the wrong levels.

Solution: Use only spaces. jobs and on are at the root level (no indentation). The elements of steps must have the same indentation (usually 6 spaces if you use 2 per level). An online YAML validator helps you locate the error.

The job passes but the tests fail locally

Cause: Environment differences: Python version, environment variables, paths.

Solution: Check that the Python version in the workflow matches the one you use locally. If you use environment variables in the tests (mocked API keys, etc.), configure them in the workflow with env: in the step or in the job.


Project Connection

This basic workflow is the first brick of Module 7's project: a working CI pipeline. In capsule 03 you'll add handling of pytest's output, artifacts and test categories. In 04, matrix testing and caching. In 05, coverage and branch protection. The workflow you created here will be the foundation you keep expanding.


Summary

  • CI runs tests automatically on every push/PR — it prevents broken code from reaching main
  • GitHub Actions uses workflows (YAML files), jobs, steps and runners
  • A minimal workflow: checkout → setup Python → install deps → run pytest
  • Triggers: push, pull_request, workflow_dispatch
  • Claude Code can generate the workflow YAML if you give it the project's context
  • The workflow lives in .github/workflows/ and runs on GitHub's clean runners

Next capsule: Running pytest in CI — handling the output, artifacts, environment variables and common problems.


Additional Resources

  1. GitHub Actions Documentation - Official documentation
  2. workflow syntax for GitHub Actions - The complete YAML syntax
  3. Building and testing Python - The guide for Python
  4. actions/checkout - The checkout action
  5. actions/setup-python - The action for Python

Module 7, Capsule 02 — Testing with Claude Code Guide