Module 1: Introduction to CI/CD and GitHub Actions

8. Project — First CI Workflow

Project description

It's time to consolidate everything you learned in this module into a working project. You're going to create a complete CI workflow for an AI project that:

  1. Runs automatically on every push and PR
  2. Has multiple jobs: lint, test, and a health check
  3. Uses dependencies between jobs (sequential where it matters, parallel where it's possible)
  4. Includes diagnostic steps and appropriate timeouts
  5. Can be run manually with workflow_dispatch

The result is a working CI pipeline that protects your code from the first commit. It's not a theoretical exercise — it's a real workflow that will live in your repository and run every time you push.


Project objective

Build a CI workflow with GitHub Actions that automatically validates a Python AI project on every push, with lint, tests, and a health check.


Prerequisites

  • ✅ You completed capsules 01-07 of this module
  • ✅ You have a GitHub repository where you can push
  • ✅ You have Python 3.10+ installed locally
  • ✅ You have access to your repo's Actions tab

Technical specifications

Project structure

my-ai-project/
├── .github/
│   └── workflows/
│       └── ci.yml              ← Your workflow (you'll create it)
├── src/
│   ├── __init__.py
│   ├── main.py                 ← Main code
│   └── utils.py                ← Utility functions
├── tests/
│   ├── __init__.py
│   ├── test_main.py            ← Tests for main
│   └── test_utils.py           ← Tests for utils
├── requirements.txt            ← Dependencies
├── pyproject.toml              ← ruff config
└── README.md

Project files

src/__init__.py

(Empty file — needed so Python treats src/ as a package)

src/main.py

"""Main module: simulation of an AI service."""


def greet(name: str) -> str:
    """Generate a personalized greeting."""
    if not name or not name.strip():
        return "Hello, anonymous user! Welcome to the AI service."
    return f"Hello, {name.strip()}! Welcome to the AI service."


def classify_sentiment(text: str) -> dict:
    """
    Simulate sentiment classification.
    In production, this would call an LLM.
    """
    if not text:
        raise ValueError("Text cannot be empty")

    text_lower = text.lower()

    positive_words = ["good", "great", "excellent", "happy", "love", "amazing"]
    negative_words = ["bad", "terrible", "awful", "hate", "horrible", "worst"]

    pos_count = sum(1 for word in positive_words if word in text_lower)
    neg_count = sum(1 for word in negative_words if word in text_lower)

    if pos_count > neg_count:
        sentiment = "positive"
        confidence = min(0.5 + (pos_count * 0.15), 0.99)
    elif neg_count > pos_count:
        sentiment = "negative"
        confidence = min(0.5 + (neg_count * 0.15), 0.99)
    else:
        sentiment = "neutral"
        confidence = 0.5

    return {
        "text": text,
        "sentiment": sentiment,
        "confidence": round(confidence, 2),
    }


if __name__ == "__main__":
    print(greet("AI Engineer"))
    result = classify_sentiment("This is a great and amazing product!")
    print(f"Sentiment: {result['sentiment']} (confidence: {result['confidence']})")

src/utils.py

"""Utility functions for the AI service."""


def estimate_tokens(text: str) -> int:
    """
    Estimate the number of tokens in a text.
    Approximation: ~4 characters per token (rule of thumb for English).
    """
    if not text:
        return 0
    return max(1, len(text) // 4)


def calculate_cost(
    prompt_tokens: int,
    completion_tokens: int,
    model: str = "gpt-4o-mini",
) -> dict:
    """
    Calculate the estimated cost of an LLM call.
    Approximate prices per 1M tokens (March 2026).
    """
    pricing = {
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4-turbo": {"input": 10.00, "output": 30.00},
    }

    if model not in pricing:
        raise ValueError(f"Unknown model: {model}. Available: {list(pricing.keys())}")

    prices = pricing[model]
    input_cost = (prompt_tokens / 1_000_000) * prices["input"]
    output_cost = (completion_tokens / 1_000_000) * prices["output"]
    total_cost = input_cost + output_cost

    return {
        "model": model,
        "prompt_tokens": prompt_tokens,
        "completion_tokens": completion_tokens,
        "input_cost": round(input_cost, 6),
        "output_cost": round(output_cost, 6),
        "total_cost": round(total_cost, 6),
    }


def validate_prompt(prompt: str, max_tokens: int = 4000) -> dict:
    """Validate that a prompt meets the basic constraints."""
    issues = []

    if not prompt:
        issues.append("Prompt is empty")
    if len(prompt) < 10:
        issues.append("Prompt is too short (min 10 characters)")

    estimated_tokens = estimate_tokens(prompt)
    if estimated_tokens > max_tokens:
        issues.append(
            f"Prompt too long: ~{estimated_tokens} tokens (max {max_tokens})"
        )

    return {
        "valid": len(issues) == 0,
        "estimated_tokens": estimated_tokens,
        "issues": issues,
    }

tests/__init__.py

(Empty file)

tests/test_main.py

"""Tests for the main module."""
import pytest
from src.main import greet, classify_sentiment


class TestGreet:
    def test_greet_with_name(self):
        result = greet("Alice")
        assert result == "Hello, Alice! Welcome to the AI service."

    def test_greet_empty_string(self):
        result = greet("")
        assert "anonymous" in result

    def test_greet_whitespace_only(self):
        result = greet("   ")
        assert "anonymous" in result

    def test_greet_strips_whitespace(self):
        result = greet("  Bob  ")
        assert "Bob" in result
        assert "  Bob  " not in result


class TestClassifySentiment:
    def test_positive_sentiment(self):
        result = classify_sentiment("This is great and amazing!")
        assert result["sentiment"] == "positive"
        assert result["confidence"] > 0.5

    def test_negative_sentiment(self):
        result = classify_sentiment("This is terrible and awful!")
        assert result["sentiment"] == "negative"
        assert result["confidence"] > 0.5

    def test_neutral_sentiment(self):
        result = classify_sentiment("The sky is blue.")
        assert result["sentiment"] == "neutral"
        assert result["confidence"] == 0.5

    def test_empty_text_raises(self):
        with pytest.raises(ValueError, match="Text cannot be empty"):
            classify_sentiment("")

    def test_result_structure(self):
        result = classify_sentiment("Hello world")
        assert "text" in result
        assert "sentiment" in result
        assert "confidence" in result
        assert result["sentiment"] in ["positive", "negative", "neutral"]
        assert 0 <= result["confidence"] <= 1.0

tests/test_utils.py

"""Tests for the utility functions."""
import pytest
from src.utils import estimate_tokens, calculate_cost, validate_prompt


class TestEstimateTokens:
    def test_empty_string(self):
        assert estimate_tokens("") == 0

    def test_short_text(self):
        result = estimate_tokens("Hello")
        assert result >= 1

    def test_longer_text(self):
        text = "This is a longer piece of text for token estimation."
        result = estimate_tokens(text)
        assert 10 <= result <= 20

    def test_returns_integer(self):
        result = estimate_tokens("test")
        assert isinstance(result, int)


class TestCalculateCost:
    def test_gpt4o_mini_cost(self):
        result = calculate_cost(1000, 500, "gpt-4o-mini")
        assert result["model"] == "gpt-4o-mini"
        assert result["total_cost"] > 0
        assert result["input_cost"] >= 0
        assert result["output_cost"] >= 0

    def test_unknown_model_raises(self):
        with pytest.raises(ValueError, match="Unknown model"):
            calculate_cost(100, 100, "gpt-5-imaginary")

    def test_zero_tokens(self):
        result = calculate_cost(0, 0, "gpt-4o-mini")
        assert result["total_cost"] == 0

    def test_cost_structure(self):
        result = calculate_cost(1000, 500, "gpt-4o")
        required_keys = [
            "model", "prompt_tokens", "completion_tokens",
            "input_cost", "output_cost", "total_cost",
        ]
        for key in required_keys:
            assert key in result, f"Missing key: {key}"


class TestValidatePrompt:
    def test_valid_prompt(self):
        result = validate_prompt("Summarize the following text in 3 sentences.")
        assert result["valid"] is True
        assert len(result["issues"]) == 0

    def test_empty_prompt(self):
        result = validate_prompt("")
        assert result["valid"] is False
        assert any("empty" in issue.lower() for issue in result["issues"])

    def test_short_prompt(self):
        result = validate_prompt("Hi")
        assert result["valid"] is False
        assert any("short" in issue.lower() for issue in result["issues"])

    def test_token_estimation_included(self):
        result = validate_prompt("A normal prompt for testing purposes.")
        assert "estimated_tokens" in result
        assert result["estimated_tokens"] > 0

requirements.txt

pytest>=8.0
ruff>=0.3.0

pyproject.toml

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "I", "W"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

Step by step: Implementation

Step 1: Check the project locally

# Install dependencies
pip install -r requirements.txt

# Run the code
python src/main.py

Expected output:

Hello, AI Engineer! Welcome to the AI service.
Sentiment: positive (confidence: 0.8)
# Run lint
ruff check src/ tests/

Expected output: No errors (empty output or "All checks passed").

# Run tests
pytest tests/ -v

Expected output:

tests/test_main.py::TestGreet::test_greet_with_name PASSED
tests/test_main.py::TestGreet::test_greet_empty_string PASSED
tests/test_main.py::TestGreet::test_greet_whitespace_only PASSED
tests/test_main.py::TestGreet::test_greet_strips_whitespace PASSED
tests/test_main.py::TestClassifySentiment::test_positive_sentiment PASSED
tests/test_main.py::TestClassifySentiment::test_negative_sentiment PASSED
tests/test_main.py::TestClassifySentiment::test_neutral_sentiment PASSED
tests/test_main.py::TestClassifySentiment::test_empty_text_raises PASSED
tests/test_main.py::TestClassifySentiment::test_result_structure PASSED
tests/test_utils.py::TestEstimateTokens::test_empty_string PASSED
tests/test_utils.py::TestEstimateTokens::test_short_text PASSED
tests/test_utils.py::TestEstimateTokens::test_longer_text PASSED
tests/test_utils.py::TestEstimateTokens::test_returns_integer PASSED
tests/test_utils.py::TestCalculateCost::test_gpt4o_mini_cost PASSED
tests/test_utils.py::TestCalculateCost::test_unknown_model_raises PASSED
tests/test_utils.py::TestCalculateCost::test_zero_tokens PASSED
tests/test_utils.py::TestCalculateCost::test_cost_structure PASSED
tests/test_utils.py::TestValidatePrompt::test_valid_prompt PASSED
tests/test_utils.py::TestValidatePrompt::test_empty_prompt PASSED
tests/test_utils.py::TestValidatePrompt::test_short_prompt PASSED
tests/test_utils.py::TestValidatePrompt::test_token_estimation_included PASSED

========================= 21 passed in 0.05s =========================

All the checks must pass locally before creating the workflow.


Step 2: Create the CI workflow

mkdir -p .github/workflows

Create .github/workflows/ci.yml:

name: CI Pipeline

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

env:
  PYTHON_VERSION: "3.12"

jobs:
  # ──────────── JOB 1: LINT ────────────
  lint:
    name: Code Quality
    runs-on: ubuntu-latest
    timeout-minutes: 5

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

      - name: Setup Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install linting tools
        run: pip install ruff

      - name: Run linting
        run: ruff check src/ tests/

      - name: Check formatting
        run: ruff format --check src/ tests/

  # ──────────── JOB 2: TEST ────────────
  test:
    name: Tests
    runs-on: ubuntu-latest
    timeout-minutes: 10

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

      - name: Setup Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/ -v --tb=short

  # ──────────── JOB 3: HEALTH CHECK ────────────
  health-check:
    name: Project Health
    needs: [lint, test]
    runs-on: ubuntu-latest
    timeout-minutes: 5

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

      - name: Setup Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Verify main script runs
        run: python src/main.py

      - name: Show project info
        run: |
          echo "✅ All checks passed!"
          echo ""
          echo "Project summary:"
          echo "  Python version: $(python --version)"
          echo "  Pytest version: $(pytest --version)"
          echo "  Ruff version: $(pip show ruff | grep Version)"
          echo ""
          echo "  Source files: $(find src -name '*.py' | wc -l)"
          echo "  Test files: $(find tests -name '*.py' | wc -l)"
          echo ""
          echo "Pipeline: lint ✅ → test ✅ → health-check ✅"

Workflow architecture

Push/PR
  │
  ├──► lint (Code Quality)         ──┐
  │    ├── checkout                  │
  │    ├── setup python              │
  │    ├── install ruff              │
  │    ├── ruff check                │
  │    └── ruff format --check       │
  │                                  │
  ├──► test (Tests)                ──┤  (parallel)
  │    ├── checkout                  │
  │    ├── setup python              │
  │    ├── install deps              │
  │    └── pytest                    │
  │                                  │
  └──► health-check (Project Health) │  (sequential: waits for lint + test)
       needs: [lint, test]         ──┘
       ├── checkout
       ├── setup python
       ├── install deps
       ├── run main.py
       └── show project info
  • lint and test run in parallel (faster)
  • health-check waits for both to pass (final validation)

Step 3: Commit and push

git add .
git commit -m "Add CI pipeline with lint, test, and health check"
git push origin main

Step 4: Check on GitHub

  1. Go to your repo → Actions
  2. You'll see "CI Pipeline" running
  3. You'll see 3 jobs: lint, test (parallel), and health-check (waiting)
  4. If everything passes: green ✅ on all 3 jobs

Expected timeline:

t=0s   Push detected
t=2s   lint and test start (parallel)
t=20s  lint complete ✅
t=35s  test complete ✅
t=37s  health-check starts
t=50s  health-check complete ✅

Step 5: Test with a PR

Create a branch, make a change, and open a PR:

git checkout -b feature/add-token-counter

Add a function to src/utils.py:

def count_words(text: str) -> int:
    """Count the words in a text."""
    if not text or not text.strip():
        return 0
    return len(text.split())

Add a test in tests/test_utils.py:

from src.utils import estimate_tokens, calculate_cost, validate_prompt, count_words

class TestCountWords:
    def test_normal_text(self):
        assert count_words("Hello world") == 2

    def test_empty_string(self):
        assert count_words("") == 0

    def test_whitespace_only(self):
        assert count_words("   ") == 0
git add .
git commit -m "Add word counter function with tests"
git push origin feature/add-token-counter

Open a PR on GitHub. You'll see the CI pipeline run automatically as a status check on the PR. The reviewer can see whether CI passed before approving.


Completeness checklist

Check that your project meets all the requirements:

Workflow

  • The .github/workflows/ci.yml file exists
  • Triggers: push (main), pull_request (main), workflow_dispatch
  • paths-ignore excludes markdown files
  • Global variable PYTHON_VERSION defined
  • 3 jobs: lint, test, health-check
  • lint and test run in parallel
  • health-check depends on lint and test (needs)
  • All the jobs have timeout-minutes
  • All the steps have a descriptive name

Code

  • src/main.py with the greet and classify_sentiment functions
  • src/utils.py with the estimate_tokens, calculate_cost, validate_prompt functions
  • __init__.py files in src/ and tests/
  • pyproject.toml with the ruff and pytest config

Tests

  • tests/test_main.py with tests for greet and classify_sentiment
  • tests/test_utils.py with tests for estimate_tokens, calculate_cost, validate_prompt
  • All the tests pass locally with pytest tests/ -v
  • Lint passes locally with ruff check src/ tests/

Verification on GitHub

  • The workflow runs on push to main
  • The 3 jobs show up in the Actions UI
  • lint and test run in parallel
  • health-check waits for lint and test
  • All the jobs pass with ✅
  • The status check shows up on commits and PRs

Project troubleshooting

"ruff format --check fails"

ruff format --check verifies that the code is formatted correctly without modifying it. If it fails, run locally:

ruff format src/ tests/

This formats the code. Then commit the changes.

"pytest doesn't find the modules"

If you see ModuleNotFoundError: No module named 'src', check that pyproject.toml has:

[tool.pytest.ini_options]
pythonpath = ["."]

This adds the current directory to the PYTHONPATH, allowing imports like from src.main import greet.

"health-check is skipped"

If health-check shows up as ⚪ (skipped), it means lint or test failed. Fix the job that failed first — health-check will run automatically when both pass.

"The workflow takes too long"

The normal times are:

  • lint: 15-30 seconds
  • test: 20-40 seconds
  • health-check: 15-30 seconds
  • Total: ~1-2 minutes

If it takes more than 5 minutes, check whether a step is hanging.


Success criteria

Your project is complete when:

CriterionStatus
Working CI workflow with 3 jobs
lint and test run in parallel
health-check depends on both
21+ tests pass in CI
Linting passes with no warnings
workflow_dispatch works
Status checks show up on PRs

Optional extensions

If you finished and want to go further:

1. Add a badge to the README

![CI Pipeline](https://github.com/YOUR_USERNAME/my-ai-project/actions/workflows/ci.yml/badge.svg)

2. Add a coverage step

pip install pytest-cov
      - name: Run tests with coverage
        run: pytest tests/ -v --cov=src --cov-report=term-missing

3. Try act locally

brew install act    # macOS
act push            # Simulates the workflow

What you built

By completing this project you have:

  • ✅ A working CI pipeline that runs on every push and PR
  • ✅ Automatic validation of code (lint) and behavior (tests) in parallel
  • ✅ A health check that confirms the whole pipeline passed
  • ✅ A manual trigger for testing and debugging
  • ✅ The base on which you'll build the following modules

Next module

Module 2 (Automated Testing in CI) takes this pipeline and brings it to the next level:

  • Matrix testing: Run your tests on Python 3.10, 3.11, and 3.12 simultaneously
  • Dependency caching: Cut pip install time from 30 seconds to 2 seconds
  • Test reports: Generate JUnit XML reports and coverage reports visible on GitHub
  • Timeouts for LLM tests: Configure protections for tests that call external APIs

The transition is direct: "You already have a pipeline that runs tests → now let's make it faster, more robust, and able to support AI tests."


Additional resources

  1. GitHub Actions Starter Workflows - Official CI templates for Python
  2. Ruff Documentation - Complete ruff documentation
  3. pytest Configuration - pytest configuration options
  4. pyproject.toml - Guide to configuring Python projects
  5. GitHub Actions workflow visualization - How to visualize the workflow's architecture
  6. GitHub branch protection rules - Configure protections with status checks