CI/CD Pipeline for a Containerized AI

Build a GitHub Actions pipeline that tests and builds a Docker sentiment API.

Introduction

30 Second Summary

Every time you save a file, how do you know it didn't break something else in your codebase? Professional software teams solve this with automated pipelines that test, lint, and package their code every single time a change is pushed.

In this project, you will build a complete CI/CD pipeline for a sentiment analysis API. You will write the API, containerize it with Docker, write automated tests, and configure GitHub Actions to run everything automatically on every push.

What You'll Build

A fully automated CI/CD pipeline that lints, tests, and builds a containerized Python API performing AI-powered sentiment analysis.

By the end of this project, you'll have:

  • A Flask REST API with a health check endpoint and a sentiment analysis endpoint that classifies text as positive, negative, or neutral.
  • A Dockerfile that packages your application into a portable container image, ready to run anywhere.
  • A GitHub Actions workflow that automatically lints your code, runs your test suite, and builds the Docker image on every push.
  • Secret Mission: Add a code coverage report to your pipeline and enforce a minimum coverage threshold.

Are there any prerequisites?

You should have basic Python knowledge and familiarity with Git (clone, commit, push). No prior experience with Docker, CI/CD, or GitHub Actions is needed.

Set Up Your Development Environment

Before you can build a pipeline, you need the tools that make it possible. Docker packages your app into a portable container, and Python runs the code inside it.

In this step, you will install Docker Desktop and Python 3.12, then create a GitHub repository with the file structure your project needs.

In this step, get ready to:
  • Install Docker Desktop and verify it works.
  • Install Python 3.12 and create a virtual environment.
  • Create a public GitHub repository and set up the project files.
Install Docker Desktop

Docker lets you package your application and all its dependencies into a single container image. This means your app runs identically on your laptop, in a test environment, and in production.

  • Check if Docker is already installed by running this command in your terminal:
docker --version

✔️ I see a version number

Docker is ready to go. You should see output like Docker version 28.x.x, build ... confirming the CLI is installed.

Skip ahead to the next substep.

ⓧ Command not found

Docker is not installed yet. Follow these steps to install it.

  • Open the Docker Desktop download page: https://docs.docker.com/get-started/introduction/get-docker-desktop/
  • Download the installer for your operating system (macOS or Windows).
  • Run the installer and follow the on-screen prompts to complete the setup.
  • Once installation finishes, close and reopen your terminal.
  • Run docker --version again to confirm it prints a version number.

Is Docker Desktop free?

Docker Desktop is free for personal use, education, non-commercial open source, and small businesses with fewer than 250 employees. This project falls under personal use, so there is no cost.

Still seeing command not found after installing?

Make sure Docker Desktop is running. On macOS, look for the whale icon in the menu bar. On Windows, check the system tray.

If you just installed Docker Desktop, you may need to restart your computer for the docker command to be available in your terminal.

help me troubleshoot Docker Desktop installation

Install Python 3.12

Your sentiment analysis API and test suite both run on Python. This project uses Python 3.12 specifically, so you need to verify you have the correct version.

  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see Python 3.12

Your output shows Python 3.12.x (any patch number is fine). Python is ready. Skip ahead to the next substep.

ⓧ I see a version lower than 3.12

Your system has an older Python version installed. On macOS, the default python3 command often points to an older version that ships with the OS. You need to install Python 3.12 alongside it.

  • Download Python 3.12 from https://www.python.org/downloads/.
  • Run the installer and follow the prompts.
  • On macOS, after installing, use python3.12 --version to verify the new version is available. You may need to use python3.12 instead of python3 for the rest of this project if the default still points to the older version.
  • On Windows, the installer updates the default python command automatically. Run python --version again to confirm.

ⓧ Command not found

Python is not installed on your system.

  • Download Python 3.12 from https://www.python.org/downloads/.
  • Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal after installation.
  • Verify by running python3 --version (macOS) or python --version (Windows). You should now see Python 3.12.x.

Python version still showing an older number?

On macOS, the system python3 may shadow your new install. Try running python3.12 --version directly. If that works, use python3.12 in place of python3 for the remaining commands in this project.

help me get Python 3.12 working on my system

Create the project repository and file structure

Your project needs a public GitHub repository so that GitHub Actions can run your CI/CD pipeline for free. Public repositories get unlimited Actions minutes on standard runners.

  • Go to https://github.com/new in your browser.
  • Enter sentiment-pipeline as the Repository name.
  • Select Public for visibility.
  • Check the box for Add a README file.
  • Click Create repository.

Why a public repository?

GitHub Actions is free and unlimited for public repositories using standard runners. This means your CI/CD pipeline will run at no cost every time you push code.

Now clone the repository to your local machine and open it in VS Code.

  • Open your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Clone your new repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/sentiment-pipeline.git
  • Move into the project folder by running:
cd sentiment-pipeline
  • Open the project in VS Code by running:
code .

code command not found?

If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter. Then click File > Open Folder and select the sentiment-pipeline folder on your Desktop.

help me open my project in VS Code

Next, set up a Python virtual environment inside your project folder. A virtual environment keeps this project's packages isolated from your system Python.

  • Create and activate a virtual environment by running these commands:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python -m venv venv
venv\Scripts\activate

How do I know the virtual environment is active?

When the virtual environment is active, your terminal prompt shows (venv) at the beginning of the line. This confirms that any packages you install will go into the project's isolated environment.

Finally, create the empty files and folders your project needs.

  • Create the project file structure by running these commands:

macOS

mkdir -p tests .github/workflows
touch app.py requirements.txt Dockerfile tests/test_app.py .github/workflows/ci.yml

Windows

mkdir tests, .github/workflows
New-Item app.py, requirements.txt, Dockerfile, tests/test_app.py, .github/workflows/ci.yml

What are these files for?

app.py holds your Flask API. requirements.txt lists your Python dependencies. Dockerfile defines how to package the app into a container.

tests/test_app.py contains your automated test suite. .github/workflows/ci.yml is where your GitHub Actions pipeline lives.

  • Confirm the files exist by running:
ls

You should see app.py, requirements.txt, Dockerfile, and the tests and .github folders listed in the output.

Don't see all the files?

Hidden files (those starting with a dot) may not appear in a plain ls output. Run ls -a to show hidden files and confirm .github is there.

If any files are missing, re-run the touch or New-Item command for the missing file specifically.

help me fix my project file structure

Your development environment is ready. Next up, you will write the Flask API that performs sentiment analysis on text.

Build the Sentiment Analysis API

A CI/CD pipeline needs an application to integrate, test, and deliver. In this step, you will build the actual service that your pipeline will automate: a REST API that performs sentiment analysis on text.

You will use Flask to create the API endpoints and TextBlob to handle the natural language processing. By the end of this step, you will have a working API you can send text to and get back a sentiment score.

In this step, get ready to:
  • Install Flask, TextBlob, and testing tools as project dependencies.
  • Write a Flask API with a health check endpoint and a sentiment analysis endpoint.
  • Test the API manually to confirm it returns sentiment results.
Install the project dependencies

Before writing any application code, you need to declare which packages the project depends on. This keeps your environment reproducible for both local development and your CI pipeline later.

  • Open requirements.txt in VS Code and add the following dependencies:
Flask==3.1.3
textblob==0.20.0
pytest==8.3.0
flake8==7.3.0

What does this file do?

A requirements.txt file lists every Python package your project needs, pinned to exact versions. This ensures everyone (and every CI runner) installs the same dependencies.

  • Flask is the web framework for your API endpoints.
  • textblob handles sentiment analysis using natural language processing.
  • pytest runs your automated test suite.
  • flake8 checks your code for style issues (linting).
  • Save requirements.txt.
  • Install all the dependencies by running this command in your terminal (make sure your virtual environment is active):
pip install -r requirements.txt

You should see pip downloading and installing Flask, TextBlob, pytest, and flake8 along with their sub-dependencies.

Seeing a permissions error or "pip not found"?

Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If it is not showing, activate it again with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Help me fix my pip install error

TextBlob also needs language data (called corpora) to analyze text. Download them now.

  • Download the TextBlob corpora by running:
python -m textblob.download_corpora

You should see output indicating that several NLTK packages are being downloaded. This only needs to happen once per environment.

Download failing or timing out?

This command downloads data from the internet. Check your network connection and try again. If you see a Python version error, confirm you are using the same python that your virtual environment points to.

Help me fix my TextBlob corpora download

Write the Flask application

Now you will write the actual API. The app needs two endpoints: one for health checks (so your pipeline can verify the service is alive) and one for sentiment analysis.

  • Open app.py in VS Code and add the imports, app instance, and health endpoint:
from flask import Flask, request, jsonify
from textblob import TextBlob

# Create the Flask application instance
app = Flask(__name__)


# Health check endpoint - returns 200 OK to confirm the service is running
@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"})

What does this code do?

  • Flask, request, and jsonify are Flask utilities for handling HTTP requests and returning JSON responses.
  • TextBlob is imported for the sentiment analysis you will add next.
  • The /health endpoint is a standard pattern in production services. CI/CD pipelines and load balancers hit this endpoint to confirm your app is alive.

The health endpoint is simple by design. Now add the main feature: sentiment analysis.

  • Add the /analyze endpoint and the main entry point below the health function in app.py:
# Sentiment analysis endpoint - accepts JSON with a "text" field
@app.route("/analyze", methods=["POST"])
def analyze():
    data = request.get_json()

    # Validate that the request contains a "text" field
    if not data or "text" not in data:
        return jsonify({"error": "Missing 'text' field in request body"}), 400

    text = data["text"]

    # Validate that the text is a non-empty string
    if not isinstance(text, str) or len(text.strip()) == 0:
        return jsonify({"error": "The 'text' field must be a non-empty string"}), 400

    # Run sentiment analysis using TextBlob
    blob = TextBlob(text)
    polarity = blob.sentiment.polarity

    # Classify based on polarity score (-1.0 to 1.0)
    if polarity > 0:
        sentiment = "positive"
    elif polarity < 0:
        sentiment = "negative"
    else:
        sentiment = "neutral"

    return jsonify({
        "text": text,
        "sentiment": sentiment,
        "polarity": round(polarity, 4)
    })


# Run the Flask development server when executed directly
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

  • request.get_json() parses the incoming JSON body from the POST request.
  • The two if blocks validate input before processing. If the text field is missing or empty, the API returns a 400 error instead of crashing.
  • TextBlob(text).sentiment.polarity returns a float between -1.0 (very negative) and 1.0 (very positive). Zero means neutral.
  • The if __name__ == "__main__" block starts the Flask dev server when you run the file directly with python app.py.
  • Save app.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full app.py file for comparison:

from flask import Flask, request, jsonify
from textblob import TextBlob

app = Flask(__name__)


@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"})


@app.route("/analyze", methods=["POST"])
def analyze():
    data = request.get_json()

    if not data or "text" not in data:
        return jsonify({"error": "Missing 'text' field in request body"}), 400

    text = data["text"]

    if not isinstance(text, str) or len(text.strip()) == 0:
        return jsonify({"error": "The 'text' field must be a non-empty string"}), 400

    blob = TextBlob(text)
    polarity = blob.sentiment.polarity

    if polarity > 0:
        sentiment = "positive"
    elif polarity < 0:
        sentiment = "negative"
    else:
        sentiment = "neutral"

    return jsonify({
        "text": text,
        "sentiment": sentiment,
        "polarity": round(polarity, 4)
    })


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
Test the API manually

Before automating anything, you should verify the API works by hand. This is the same sanity check you would do in a real development workflow before writing tests or wiring up a pipeline.

  • Start the Flask development server by running:
python app.py

You should see output indicating Flask is running on http://0.0.0.0:5000. Leave this terminal running.

  • Open a second terminal window. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) in VS Code, then type Terminal: Create New Terminal and press Enter.
  • Test the health endpoint by running:
curl http://localhost:5000/health

You should see the response:

{"status":"ok"}
  • Test the sentiment analysis endpoint by running:
curl -X POST http://localhost:5000/analyze -H "Content-Type: application/json" -d "{\"text\": \"I love this product, it is amazing!\"}"

You should see a JSON response containing the original text, a sentiment classification of positive, and a polarity score greater than zero.

What does this curl command do?

  • -X POST sends a POST request instead of the default GET.
  • -H "Content-Type: application/json" tells the server you are sending JSON data.
  • -d provides the request body containing the text to analyze.
  • Stop the Flask server by pressing Ctrl+C in the first terminal.

Getting "Connection refused" or no response?

Make sure the Flask server is still running in your first terminal. You should see the Running on http://0.0.0.0:5000 message. If it crashed, check app.py for syntax errors and run python app.py again.

On Windows, if curl is not recognized, use PowerShell which includes curl as an alias. Or use the VS Code terminal which supports it by default.

Help me debug my Flask API connection

Your API is working and returning sentiment scores. Next, you will package this application into a Docker container so it can run identically on any machine.

Containerize the Application with Docker

Your sentiment analysis API is working locally. But what happens when you need to run it on a different machine, or hand it off to a teammate with a different operating system?

This is where Docker comes in. Docker packages your application and all its dependencies into a single, portable image that runs identically on any machine. In this step, you'll write a Dockerfile and build an image that contains your API.

In this step, get ready to:
  • Write a Dockerfile that packages the API into a portable container image.
  • Build and run the container to verify it works identically to the local version.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build your image. Each line adds a layer to the image, starting from a base image and ending with the command that runs your app.

  • In VS Code, open the empty Dockerfile (no file extension) in your sentiment-pipeline folder.
  • Paste the following code into the file:
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m textblob.download_corpora

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python 3.12 base image. The slim variant keeps the image small by excluding unnecessary packages.
  • WORKDIR /app sets the working directory inside the container. All subsequent commands run from here.
  • COPY requirements.txt . copies your dependencies file into the container first. Docker caches each layer, so if your code changes but dependencies don't, Docker skips reinstalling them.
  • RUN pip install --no-cache-dir -r requirements.txt installs your Python packages without caching downloaded files (saves image space).
  • RUN python -m textblob.download_corpora downloads the language data TextBlob needs for sentiment analysis.
  • COPY app.py . copies your application code into the container.
  • EXPOSE 5000 documents that the container listens on port 5000.
  • CMD ["python", "app.py"] defines the command that runs when the container starts.
  • Save Dockerfile.

Why copy requirements.txt before app.py?

Docker builds images in layers, and it caches layers that haven't changed. By copying and installing dependencies first, Docker only reinstalls packages when requirements.txt changes. If you only edit app.py, Docker reuses the cached dependency layer and rebuilds much faster.

Seeing a syntax error in the Dockerfile?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. If VS Code added a .txt extension, rename the file.

Help me troubleshoot my Dockerfile

✔️ Awesome, I've got everything!

Great. Double check you've saved Dockerfile before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m textblob.download_corpora

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
Build and run the Docker container

Now you'll turn that Dockerfile into a runnable image and launch it as a container.

  • Make sure you stopped the Flask development server from the previous step. If it's still running in your terminal, press Ctrl+C to stop it.
  • Build the Docker image by running this command:
docker build -t sentiment-api .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t sentiment-api tags (names) the resulting image so you can reference it easily.
  • . tells Docker to use the current directory as the build context (where it looks for files to copy).

The build takes a minute or two as Docker downloads the base image and installs your dependencies. You'll see each step of the Dockerfile executing in the terminal output.

Build failing or hanging?

  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray (Windows) or menu bar (macOS).
  • Verify you're in the sentiment-pipeline folder (the one containing your Dockerfile).
  • If you see a network error during pip install, check your internet connection and retry.

Help me debug my Docker build

  • Once the build completes, run the container by executing:
docker run -p 5000:5000 sentiment-api

What does this command do?

-p 5000:5000 maps port 5000 on your machine to port 5000 inside the container. This means requests to localhost:5000 on your machine are forwarded into the container where Flask is listening.

The container runs in the foreground and you'll see Flask's startup message. Your API is now running inside a Docker container.

  • Open a new terminal tab or window to test the containerized API.
  • Send a test request by running:
curl -X POST http://localhost:5000/analyze -H "Content-Type: application/json" -d '{"text": "I love this product, it is amazing!"}'

You should see a JSON response with a positive sentiment and a polarity score greater than 0. This is the same result you got when running the API directly with Python.

Port already in use?

If you see an error about port 5000 being in use, either your Flask development server is still running or another process is using that port. Stop it with Ctrl+C in the original terminal, then try docker run again.

On macOS, port 5000 is sometimes used by AirPlay Receiver. If that's the case, go to System Settings and disable AirPlay Receiver, or use -p 5001:5000 instead and test on localhost:5001.

Help me resolve my port conflict

  • Once you've confirmed the API is working, switch back to the terminal running the container and press Ctrl+C to stop it.

Your API is now packaged into a portable Docker image. Anyone with Docker installed can run your container and get the exact same behavior. Next up, you'll write automated tests to verify your endpoints work correctly before every deployment.

Write Automated Tests

Your API is containerized and running identically in Docker and locally. But how do you know future changes won't break it?

Automated tests answer that question. Instead of manually sending curl requests every time you edit code, you write tests that verify your endpoints automatically. In this step, you will write a test suite using pytest that your CI pipeline will run on every push.

In this step, get ready to:
  • Write a pytest fixture that creates a Flask test client.
  • Write five tests covering your health and analyze endpoints.
  • Run the test suite locally and confirm all tests pass.
Set up the test fixture and health endpoint test

Before writing individual tests, you need a way to send requests to your Flask app without actually starting a server. Flask provides a built-in test client that simulates HTTP requests in memory.

  • In VS Code, open tests/test_app.py (the empty file you created in Step 1).
  • Add the imports and the pytest fixture by pasting the following code:
import pytest
from app import app


@pytest.fixture
def client():
    # Enable testing mode so Flask returns detailed errors
    app.config["TESTING"] = True
    # Create a test client that simulates HTTP requests
    with app.test_client() as client:
        yield client

What does this code do?

The @pytest.fixture decorator defines reusable setup code. Every test function that accepts client as a parameter will automatically receive a fresh test client instance.

Setting TESTING to True tells Flask to propagate exceptions instead of returning generic error pages. The test_client() method creates a simulated HTTP client that sends requests to your app in memory, with no running server needed.

  • Below the fixture, add the first test for the health endpoint:

def test_health_endpoint(client):
    # Send a GET request to the health check endpoint
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "ok"

What does this test verify?

This test sends a GET request to /health and checks two things: that the response code is 200, and that the JSON body contains {"status": "ok"}. If either assertion fails, pytest reports exactly which check broke.

  • Save tests/test_app.py.

Seeing an import error?

Make sure your virtual environment is activated. The from app import app line imports from your project's app.py file, which requires Flask to be installed in the active environment.

Help me fix my import error in test_app.py

Write tests for the analyze endpoint

The health check is simple. Your analyze endpoint has more complex behavior: it should return correct sentiment for positive and negative text, and reject invalid requests with a 400 error.

  • Below test_health_endpoint, add these two tests that verify the happy path for positive and negative sentiment:

def test_analyze_positive_sentiment(client):
    # Send text with clearly positive language
    response = client.post("/analyze", json={"text": "I love this product, it is amazing!"})
    assert response.status_code == 200
    data = response.get_json()
    assert data["sentiment"] == "positive"
    assert data["polarity"] > 0
    assert data["text"] == "I love this product, it is amazing!"


def test_analyze_negative_sentiment(client):
    # Send text with clearly negative language
    response = client.post("/analyze", json={"text": "This is terrible and awful."})
    assert response.status_code == 200
    data = response.get_json()
    assert data["sentiment"] == "negative"
    assert data["polarity"] < 0

What do these tests verify?

Each test sends a POST request with a json body containing a text field. The positive test checks that polarity is above zero and the sentiment label is positive. The negative test checks that polarity is below zero and the label is negative.

Notice the test also verifies that the original text is echoed back in the response. This confirms the full response structure matches what your API promises.

  • Now add two more tests that verify your API rejects bad input with a 400 status code:

def test_analyze_missing_text_field(client):
    # Send JSON without the required "text" field
    response = client.post("/analyze", json={"wrong_field": "hello"})
    assert response.status_code == 400
    data = response.get_json()
    assert "error" in data


def test_analyze_empty_text(client):
    # Send whitespace-only text, which should be rejected
    response = client.post("/analyze", json={"text": "   "})
    assert response.status_code == 400
    data = response.get_json()
    assert "error" in data

Why test for errors?

Testing error cases is just as important as testing the happy path. These tests confirm that your API returns a 400 status code and an error message when the input is missing or invalid. Without these tests, a future code change could accidentally remove your validation logic and you would never know until a user hit the bug.

  • Save tests/test_app.py.

Not sure where to paste each block?

Each code block goes directly below the previous one in tests/test_app.py. The file should flow from imports at the top, to the fixture, then all five test functions in order.

Help me check my test file structure

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/test_app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete tests/test_app.py file. Make sure yours matches exactly:

import pytest
from app import app


@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client


def test_health_endpoint(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "ok"


def test_analyze_positive_sentiment(client):
    response = client.post("/analyze", json={"text": "I love this product, it is amazing!"})
    assert response.status_code == 200
    data = response.get_json()
    assert data["sentiment"] == "positive"
    assert data["polarity"] > 0
    assert data["text"] == "I love this product, it is amazing!"


def test_analyze_negative_sentiment(client):
    response = client.post("/analyze", json={"text": "This is terrible and awful."})
    assert response.status_code == 200
    data = response.get_json()
    assert data["sentiment"] == "negative"
    assert data["polarity"] < 0


def test_analyze_missing_text_field(client):
    response = client.post("/analyze", json={"wrong_field": "hello"})
    assert response.status_code == 400
    data = response.get_json()
    assert "error" in data


def test_analyze_empty_text(client):
    response = client.post("/analyze", json={"text": "   "})
    assert response.status_code == 400
    data = response.get_json()
    assert "error" in data
Run the tests locally

Your CI pipeline will execute the exact same test command you are about to run. Running tests locally first catches issues before they ever reach GitHub.

  • Run the test suite by entering this command in your terminal:
python -m pytest tests/ -v
  • Confirm that all 5 tests pass. You should see output showing each test name followed by PASSED, and a final summary line showing 5 passed.

What does the -v flag do?

The -v flag stands for verbose. It shows the full name of each test function and its pass/fail status individually, rather than just a dot per test. This makes it much easier to identify which specific test failed.

Tests failing or not found?

If pytest says "no tests ran", make sure your test file is saved at tests/test_app.py (not test_app.py in the root folder). Pytest discovers tests by looking for files matching the test_*.py pattern inside the path you provide.

If you see a ModuleNotFoundError, confirm your virtual environment is activated and you installed dependencies with pip install -r requirements.txt.

Help me debug my failing pytest tests

All five tests pass locally. Next, you will wire everything together into a GitHub Actions workflow that runs these tests automatically on every push.

Configure the GitHub Actions CI/CD Pipeline

Your tests pass locally, and your Docker image builds without errors. But running these checks by hand before every commit is something you will eventually forget.

GitHub Actions can automate the entire process. In this step you will write a workflow file that runs your linter, test suite, and Docker build automatically every time you push code.

In this step, get ready to:
  • Write a CI workflow that lints and tests your code on every push.
  • Add a Docker build job that only runs after tests pass.
  • Push your code and verify the pipeline passes in GitHub.
Write the CI workflow with the test job

A GitHub Actions workflow lives in a YAML file inside the .github/workflows/ directory. The file you created earlier (ci.yml) is still empty. You will now fill it with the test job.

  • Open .github/workflows/ci.yml in VS Code and paste the following workflow definition:
name: CI Pipeline

on:
  push:
    branches: ["*"]
  pull_request:
    branches: ["main"]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

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

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

      - name: Download TextBlob corpora
        run: python -m textblob.download_corpora

      - name: Lint with flake8
        run: flake8 app.py tests/ --max-line-length=100

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

What does this workflow do?

  • name: CI Pipeline gives the workflow a human-readable label that appears in the GitHub Actions UI.
  • The on block triggers the workflow on every push to any branch and on pull requests targeting main.
  • The test job runs on ubuntu-latest, checks out your code, installs Python 3.12 with pip caching, then installs your dependencies.
  • After setup, it downloads the TextBlob language data, lints your code with flake8, and runs your full test suite with pytest.
  • Save ci.yml.

You won't see a change yet because the workflow only runs after you push to GitHub. The next substep adds the Docker build job, then you will push everything together.

Add the Docker build job

The build job packages your application into a Docker image. It should only run after the test job passes so you never build a broken image.

  • In .github/workflows/ci.yml, add the following block directly below the last line of the test job (after the Run tests step):
  build:
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v6

      - name: Build Docker image
        run: docker build -t sentiment-api .

What does this job do?

  • needs: test tells GitHub Actions that the build job can only start after the test job completes successfully. If linting or tests fail, the Docker build is skipped entirely.
  • The job checks out the code again (each job runs on a fresh virtual machine) and builds the same Docker image you tested locally.
  • Save ci.yml.

YAML indentation errors?

YAML is whitespace-sensitive. The build key must be at the same indentation level as the test key (two spaces in from jobs:). If you see unexpected errors later, check that build: starts at column 3 (two leading spaces).

Help me fix YAML indentation in my GitHub Actions workflow file.

✔️ Awesome, I've got everything!

Great. Double check you have saved ci.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/ci.yml file. Make sure yours matches exactly:

name: CI Pipeline

on:
  push:
    branches: ["*"]
  pull_request:
    branches: ["main"]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

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

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

      - name: Download TextBlob corpora
        run: python -m textblob.download_corpora

      - name: Lint with flake8
        run: flake8 app.py tests/ --max-line-length=100

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

  build:
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v6

      - name: Build Docker image
        run: docker build -t sentiment-api .
Commit, push, and verify the pipeline

Your workflow file is complete. Now push all your project files to GitHub so the pipeline triggers automatically.

  • Stage, commit, and push all files by running these commands:
git add .
git commit -m "Add CI/CD pipeline with tests and Docker build"
git push origin main

What happens after you push?

Because your workflow triggers on push to any branch, GitHub Actions picks up the commit immediately. It spins up a fresh Ubuntu runner, installs your dependencies, runs flake8 and pytest, then (if tests pass) builds your Docker image.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.
  • Click the workflow run labelled Add CI/CD pipeline with tests and Docker build (your commit message).
  • Wait for both the test and build jobs to show a green checkmark. This confirms your linter, tests, and Docker build all passed.

Why does the build job wait?

The needs: test keyword creates a dependency chain. You can see this visually in the workflow run summary where the build job appears downstream of test. If a lint or test failure occurs, build is skipped automatically.

Pipeline failed?

Click the failed job in the Actions tab, then expand the failed step to see its log output. Common causes:

  • Lint failure: flake8 found a line longer than 100 characters or an unused import. Read the error line number, fix it locally, commit, and push again.
  • Test failure: a test that passed locally may fail if you forgot to include the Download TextBlob corpora step in your workflow. Check that it appears before the test step.
  • Docker build failure: make sure your Dockerfile and app.py are committed. Missing files cause the COPY instruction to fail.

Help me debug my failing GitHub Actions pipeline.

Your pipeline is live. Every future push triggers the same lint, test, and build sequence automatically. Next up, you will extend this pipeline with code coverage enforcement in the Secret Mission.

Secret mission

Add Code Coverage Enforcement

Your pipeline runs tests, but how do you know those tests actually cover your code? In this bonus challenge, you will add coverage reporting that measures exactly which lines your tests exercise. Then you will enforce a minimum threshold so the pipeline fails if coverage drops below 80%.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses a free public GitHub repository. There are no ongoing cloud costs.

Decide whether to keep your resources as-is, pause running processes, or delete everything entirely.

Resources you used:

  • Public GitHub repository (sentiment-pipeline).
  • Local Docker image (sentiment-api).
  • Local project files (sentiment-pipeline folder on your Desktop).

Keep everything running

No action needed. Choose this if you want to keep the repository public as a portfolio piece for your job application.

  • Your GitHub repository stays live and visible to anyone with the link.
  • Your local Docker image and project files remain on disk.
  • Docker Desktop continues to use system resources while running. You can quit it from the menu bar (macOS) or system tray (Windows) when you don't need it.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your files stay intact for when you return.

  • Stop any running Docker containers by running:
  • Quit Docker Desktop from the menu bar (macOS) or system tray (Windows).
docker stop $(docker ps -q)
  • Your GitHub repository, local files, and Docker image remain untouched and ready for your next session.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the GitHub repository:
  • Open your sentiment-pipeline repository on GitHub.
  • Click Settings (under the repository name).
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository, read the warnings, click I have read and understand these effects, type the repository name, then click Delete this repository to confirm.
  • Delete the local Docker image by running:
docker image rm sentiment-api
  • Delete the local project files (macOS/Linux) by running:
cd ~/Desktop
rm -rf sentiment-pipeline
  • On Windows, run this instead:
cd ~\Desktop
rmdir /s /q sentiment-pipeline
  • Once deleted, these resources cannot be recovered.

Nice Work!

Nice Work!

Nice work! You just built a complete CI/CD pipeline that automatically tests and builds a containerized AI service on every push.

You've learned how to:

  • Build a Flask REST API that performs AI-powered sentiment analysis and containerize it with Docker for portable deployment.
  • Write automated tests with pytest that validate API behavior, including error handling and response structure.
  • Configure a GitHub Actions CI/CD pipeline that automatically lints, tests, and builds a Docker image on every push.
  • Secret Mission: Added code coverage reporting and enforcement to maintain code quality standards with a minimum threshold.

Ready to quiz yourself?