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:
- Runs automatically on every push and PR
- Has multiple jobs: lint, test, and a health check
- Uses dependencies between jobs (sequential where it matters, parallel where it's possible)
- Includes diagnostic steps and appropriate timeouts
- 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
- Go to your repo → Actions
- You'll see "CI Pipeline" running
- You'll see 3 jobs: lint, test (parallel), and health-check (waiting)
- 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.ymlfile exists - Triggers: push (main), pull_request (main), workflow_dispatch
-
paths-ignoreexcludes markdown files - Global variable
PYTHON_VERSIONdefined - 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.pywith the greet and classify_sentiment functions -
src/utils.pywith the estimate_tokens, calculate_cost, validate_prompt functions -
__init__.pyfiles in src/ and tests/ -
pyproject.tomlwith the ruff and pytest config
Tests
-
tests/test_main.pywith tests for greet and classify_sentiment -
tests/test_utils.pywith 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:
| Criterion | Status |
|---|---|
| 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

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 installtime 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
- GitHub Actions Starter Workflows - Official CI templates for Python
- Ruff Documentation - Complete ruff documentation
- pytest Configuration - pytest configuration options
- pyproject.toml - Guide to configuring Python projects
- GitHub Actions workflow visualization - How to visualize the workflow's architecture
- GitHub branch protection rules - Configure protections with status checks