Build a CI/CD Pipeline with GitHub Actions

Automate testing and linting for a Python app using GitHub Actions.

Introduction

30 Second Summary

Every time a developer pushes code to a shared project, there is a risk of breaking something. Professional teams solve this by automating checks that catch problems before they reach production.

In this project, you will build a CI/CD pipeline using GitHub Actions that automatically lints, tests, and gates deployment of a Python application every time you push code.

What You'll Build

A complete three-stage pipeline that runs linting, testing, and deployment steps automatically on every push to GitHub.

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

  • A Python Flask API with unit tests that validate your endpoints.
  • A GitHub Actions workflow with three dependent stages: lint, test, and deploy.
  • A live pipeline that triggers automatically on every push and pull request, with a status badge showing its health.
  • Secret Mission: Extend your pipeline with matrix testing across multiple Python versions simultaneously.

Are there any prerequisites?

None. This project includes full setup instructions for all required tools, including Python, Git, and VS Code. You just need a computer with internet access.

Set Up Your Development Environment

Before you can build a CI/CD pipeline, you need the right tools on your machine.

In this step, you will install Python, Git, and VS Code, then create a GitHub repository and prepare your project with a virtual environment and dependencies.

In this step, get ready to:
  • Install and verify Python, Git, and VS Code.
  • Create a GitHub repository and clone it to your machine.
  • Set up a Python virtual environment with project dependencies.
Install and verify Python, Git, and VS Code

Your pipeline will run Python code, so you need Python installed locally to develop and test before pushing to GitHub.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check if Python 3.11 or higher is installed by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.11 or higher

You are good to go. Move on to the Git check below.

ⓧ I see an older version

Your system has a Python version below 3.11. On macOS, the system Python is often outdated and python3 may still resolve to the old version even after you install a newer one.

  • Download Python 3.11 or higher from python.org/downloads.
  • Run the installer and follow the prompts. On Windows, check the box that says Add python.exe to PATH.
  • Close and reopen your terminal, then run the version check command again.
  • If python3 --version still shows the old version on macOS, try python3.11 --version or python3.12 --version to confirm the new install is available.

ⓧ Command not found

Python is not installed on your machine yet.

  • Download Python 3.11 or higher from python.org/downloads.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH.
  • Close and reopen your terminal, then run the version check command again to confirm it works.
  • Check if Git is installed by running this command:
git --version

✔️ I see a version number

Git is ready. Move on to VS Code below.

ⓧ Command not found

Git is not installed on your machine yet.

  • Download Git from git-scm.com/downloads.
  • Run the installer with the default settings.
  • Close and reopen your terminal, then run git --version again to confirm.

Finally, you need VS Code as your code editor for this project.

  • If you do not have VS Code installed, download it from code.visualstudio.com and run the installer.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

Keep your terminal open

You will use the terminal throughout this project. Keep it open in the background for now.

Create your GitHub repository and clone it

Your pipeline needs a GitHub repository to live in. GitHub Actions workflows run inside a repository, so creating one is the first step toward automation.

  • If you do not already have a GitHub account, sign up for free at github.com.
  • Once signed in, click the + icon in the top-right corner of any GitHub page.
  • Click New repository.
  • In the Repository name field, type python-cicd-pipeline.
  • Leave the visibility set to Public.
  • Check the box labeled Add a README file.
  • Click Create repository.

Why public?

Public repositories get unlimited free GitHub Actions minutes. Private repositories are limited to 2,000 minutes per month on the free plan.

GitHub takes you to your new repository page. Now clone it to your machine.

  • On your repository page, click the green Code button.
  • Make sure the HTTPS tab is selected, then click the copy icon to copy the URL.
  • In your terminal, move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone [[REPO_URL="https://github.com/your-username/python-cicd-pipeline.git"]]
  • Move into the project folder by running:
cd python-cicd-pipeline
  • Confirm the clone worked by running:
ls

You should see a README.md file in the output.

Clone failed or permission denied?

Make sure you copied the HTTPS URL (not SSH). If you see an authentication prompt, sign in with your GitHub credentials.

Help me troubleshoot my git clone error.

Set up your virtual environment and install dependencies

A virtual environment isolates your project's Python packages from the rest of your system. This keeps your project reproducible and prevents version conflicts.

  • Open your python-cicd-pipeline folder in VS Code. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the python-cicd-pipeline folder, and click Open.
  • Open the integrated terminal in VS Code by pressing Ctrl+` (both macOS and Windows).
  • Create a virtual environment by running this command:
python3 -m venv venv

What does this command do?

python3 -m venv venv creates a new folder called venv inside your project. This folder contains a self-contained Python installation where your project's packages will live.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Not seeing (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.

On macOS, make sure you used python3 (not python) when creating the virtual environment.

Help me activate my Python virtual environment.

Now you need a requirements.txt file to declare your project dependencies. This file tells both your local environment and the CI/CD pipeline which packages to install.

  • In VS Code's Explorer sidebar (left panel), click the new file icon at the top of the file list.
  • Name the file requirements.txt and press Enter.
  • Paste the following contents into the file:
flask==3.1.1
pytest==8.3.5
flake8==7.1.2

What are these packages?

Flask is a lightweight web framework. You will use it to build the small API that your pipeline tests.

pytest is a testing framework that runs your unit tests. flake8 is a linter that catches code style issues and common mistakes.

  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Install the dependencies by running this command in your terminal:
pip install -r requirements.txt

You should see output showing each package downloading and installing successfully.

Seeing permission errors or install failures?

Make sure (venv) appears in your terminal prompt. If it does not, your virtual environment is not active. Run the activate command again before installing.

Help me fix my pip install errors.

  • Verify the installation worked by running:
pip show flask

You should see output displaying Name: Flask and Version: 3.1.1. This confirms your dependencies are installed correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt and move on to the next step.

ⓧ I'd like to double check the full code

Here is what your requirements.txt should contain:

flask==3.1.1
pytest==8.3.5
flake8==7.1.2

Your development environment is ready. Next up, you will build a simple Python API and write tests that your pipeline will run automatically.

Build a Python API with Tests

A CI/CD pipeline needs something to test. Before you can automate code quality checks, you need actual code with tests that can run in the cloud.

In this step, you will build a minimal Flask API with two endpoints, then write pytest tests that validate both endpoints. These tests become the foundation your pipeline will run automatically on every push.

In this step, get ready to:
  • Create a Flask API with a health check and greeting endpoint.
  • Write unit tests that validate both endpoints.
  • Run the tests locally to confirm everything passes.
Create the Flask API

Your API will have two routes that return JSON responses. The first is a health check that confirms the service is running.

  • In VS Code, create a new file called app.py in the root of your python-cicd-pipeline folder.
  • Add the Flask import, app instance, and your first route by pasting this code:
from flask import Flask, jsonify

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

# Health check endpoint - returns service status
@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "python-cicd-pipeline"})

What does this code do?

Flask is a lightweight Python web framework. jsonify converts Python dictionaries into JSON responses that any client can read.

The @app.route("/health") decorator maps the URL path /health to the health() function. When someone visits that path, Flask returns the JSON dictionary.

  • You won't see a change yet. The next chunk completes the file with the second route.
  • Add the greeting route and the main entry point below the health route in app.py:
# Greeting endpoint - returns personalized message
@app.route("/hello/<name>")
def hello(name):
    return jsonify({"message": f"Hello, {name}!", "status": "success"})

# Run the development server when executed directly
if __name__ == "__main__":
    app.run(debug=True)

What does this code do?

The <name> in the route is a URL variable. Flask captures whatever text appears in that position and passes it as a function argument. Visiting /hello/Alice returns {"message": "Hello, Alice!"}.

The if __name__ == "__main__" block lets you run the file directly for local testing. It starts a development server on your machine.

  • Save app.py.
  • Verify the app starts by running this command in your terminal (make sure your virtual environment is active):
python app.py
  • You should see output confirming the Flask development server is running, including a local URL like http://127.0.0.1:5000.
  • Press Ctrl+C to stop the server.

Server not starting?

  • Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • Check for typos in app.py. A missing comma or quote will cause a syntax error.
  • help me debug why my Flask app won't start

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.py before moving on.

ⓧ I'd like to double check the full code

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "python-cicd-pipeline"})


@app.route("/hello/<name>")
def hello(name):
    return jsonify({"message": f"Hello, {name}!", "status": "success"})


if __name__ == "__main__":
    app.run(debug=True)
Write unit tests for your API

Tests give your pipeline something to run. Each test sends a request to an endpoint and checks that the response matches what you expect.

  • Create a tests folder inside your python-cicd-pipeline directory by running this command:
mkdir tests
  • Confirm the folder exists by running:
ls
  • You should see tests listed alongside app.py and requirements.txt.
  • Create a new file called test_app.py inside the tests folder.
  • Add the test setup by pasting this code into tests/test_app.py:
import pytest
from app import app

# Create a test client fixture that pytest reuses for each test
@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

What is a pytest fixture?

A fixture is a reusable piece of setup code. The client fixture creates a test version of your Flask app that you can send requests to without starting a real server.

Any test function that includes client as a parameter automatically receives this test client. Pytest handles the wiring for you.

  • You won't see a change yet. The next chunk adds the test functions that use this fixture.
  • Add the first test function below your fixture in tests/test_app.py:
# Test that the health endpoint returns correct status and service name
def test_health_endpoint(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"
    assert data["service"] == "python-cicd-pipeline"

What does this test do?

The test sends a GET request to /health using the test client. It then checks three things: the HTTP status code is 200, the status field is "healthy", and the service field matches your app name.

If any assert statement fails, pytest marks the test as failed and shows you which value was wrong.

  • You won't see a change yet. The next chunk adds tests for the greeting endpoint.
  • Add the greeting endpoint tests below test_health_endpoint in tests/test_app.py:
# Test the hello endpoint with a default name
def test_hello_endpoint(client):
    response = client.get("/hello/World")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Hello, World!"
    assert data["status"] == "success"

# Test the hello endpoint with a different name
def test_hello_endpoint_with_name(client):
    response = client.get("/hello/Alice")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Hello, Alice!"

Why two tests for the same endpoint?

Testing with multiple inputs catches edge cases. The first test uses World and checks both message and status fields. The second test uses Alice to confirm the URL variable works with any name.

  • Save tests/test_app.py.

Seeing a red underline on the import?

  • VS Code might show a warning on from app import app. This is fine. The import resolves at runtime when pytest runs from the project root.
  • help me understand why VS Code shows an import error for my Flask app

✔️ Awesome, I've got everything!

Great. Make sure you've saved tests/test_app.py before running the tests.

ⓧ I'd like to double check the full code

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"] == "healthy"
    assert data["service"] == "python-cicd-pipeline"


def test_hello_endpoint(client):
    response = client.get("/hello/World")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Hello, World!"
    assert data["status"] == "success"


def test_hello_endpoint_with_name(client):
    response = client.get("/hello/Alice")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Hello, Alice!"
Run your tests locally

Before pushing code to GitHub, you want to confirm everything passes on your own machine. This is the same command your pipeline will run in the cloud.

  • Run your test suite by running this command:
python -m pytest tests/ -v
  • You should see all three tests listed with PASSED next to each one, and a summary line showing 3 passed at the bottom.

What does the -v flag do?

The -v flag stands for verbose. Instead of showing dots for passing tests, pytest lists each test function by name with its result. This makes it easier to see exactly what ran and what passed.

Tests failing?

  • Make sure you are running the command from the python-cicd-pipeline folder (the one containing app.py).
  • Check that app.py is saved and the route paths match exactly: /health and /hello/<name>.
  • If you see ModuleNotFoundError, confirm your virtual environment is active and Flask is installed.
  • help me debug why my pytest tests are failing

Your API and tests are ready. Next up, you will create a GitHub Actions workflow that runs these tests automatically every time you push code.

Create Your First GitHub Actions Workflow

Your tests pass locally, which means you know your code works on your machine. But what happens when a teammate pushes code that breaks something? You would not know until someone manually runs the tests again.

This is the problem CI/CD solves. Continuous Integration means automatically running checks every time code is pushed. In this step, you will create a GitHub Actions workflow that lints your code automatically in the cloud every time you push.

In this step, get ready to:
  • Create a workflow file that triggers on pushes and pull requests.
  • Define a lint job that checks your code quality automatically.
  • Push your code and watch the pipeline run live on GitHub.
Create the workflow file and configure triggers

GitHub Actions looks for workflow files in a specific location: the .github/workflows/ directory inside your repository. Any YAML file placed there becomes a workflow that GitHub can execute.

Why GitHub Actions over other CI/CD tools?

GitHub Actions is built directly into GitHub, so there is no extra server to set up or separate account to create. It is free for public repositories and includes 2,000 free minutes per month for private repositories.

Other tools like Jenkins require you to manage your own server. GitHub Actions lets you focus on defining your pipeline, not maintaining infrastructure.

  • Create the required directory structure by running this command:

macOS

mkdir -p .github/workflows

Windows

New-Item -ItemType Directory -Path ".github\workflows" -Force
  • Confirm the directory was created by running:
ls .github

You should see workflows in the output.

  • In VS Code's Explorer sidebar, right-click the workflows folder inside .github.
  • Select New File.
  • Name the file ci.yml.
  • Paste the following trigger configuration into ci.yml:
name: CI/CD Pipeline

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

What does this code do?

This YAML defines the metadata and trigger rules for your workflow.

  • name: CI/CD Pipeline gives the workflow a human-readable name that appears in the GitHub Actions tab.
  • on: push: branches: [main] tells GitHub to run this workflow every time code is pushed to the main branch.
  • on: pull_request: branches: [main] also triggers the workflow when someone opens a pull request targeting main.
  • Save ci.yml.

You will not see any visible change yet because the workflow needs a jobs section before it can run.

Don't see the .github folder in VS Code?

Folders starting with a dot are hidden by default on some systems. In VS Code's Explorer, check that the files.exclude setting is not hiding .github.

If you used the terminal to create the directory, make sure you ran the command from inside your python-cicd-pipeline folder.

help me find the .github folder in my VS Code project

Define the lint job

A workflow needs at least one job to do anything useful. Jobs define the actual work that runs on GitHub's cloud servers.

  • In ci.yml, add the following code below your trigger configuration:
jobs:
  lint:
    name: Lint Code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install flake8
        run: pip install flake8
      - name: Run linter
        run: flake8 app.py tests/ --max-line-length=120

What does this code do?

This defines a job called lint that runs on a fresh Ubuntu virtual machine hosted by GitHub.

  • actions/checkout@v4 downloads your repository code onto the runner machine.
  • actions/setup-python@v5 installs Python 3.11 on the runner so your tools can execute.
  • The final step runs flake8 against app.py and the tests/ folder, checking for style issues with a max line length of 120 characters.
  • Save ci.yml.

The workflow is now complete but has not run yet. You need to push it to GitHub for the triggers to activate.

Seeing red squiggles in the YAML file?

YAML is sensitive to indentation. Every level must use exactly 2 spaces, not tabs. Check that your steps: items are aligned and that with: is indented under its parent step.

help me fix YAML indentation errors 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:

name: CI/CD Pipeline

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

jobs:
  lint:
    name: Lint Code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install flake8
        run: pip install flake8
      - name: Run linter
        run: flake8 app.py tests/ --max-line-length=120
Push your code and watch the pipeline run

Your workflow file only activates once it exists on GitHub. Pushing your code will trigger the workflow for the first time.

  • Stage all your project files, commit them, and push to GitHub by running these commands:
git add .
git commit -m "Add CI/CD workflow with lint job"
git push origin main

What do these commands do?

  • git add . stages all files in your project (including app.py, tests/, requirements.txt, and the workflow file).
  • git commit saves a snapshot of your code with a descriptive message.
  • git push origin main uploads your code to the main branch on GitHub, which triggers the workflow.
  • Open your browser and go to your python-cicd-pipeline repository on GitHub.
  • Click the Actions tab at the top of the repository page.
  • Click on the workflow run titled Add CI/CD workflow with lint job to see the details.
  • Click on the Lint Code job to expand it and view the live logs for each step.

You should see a green checkmark next to the Lint Code job, confirming that your code passed the linting check.

Seeing a red X instead of a green checkmark?

Click on the failed job to read the error logs. Common causes include:

  • A trailing whitespace or unused import in app.py or test_app.py. Fix the issue locally, commit, and push again.
  • YAML indentation errors in ci.yml. Make sure every level uses exactly 2 spaces.
  • The branch name does not match. Your local branch must be main to match the trigger in the workflow file.

help me debug why my GitHub Actions lint job is failing

Your first pipeline is live. Every push to main now triggers an automatic code quality check. Next up, you will add a test job and chain it to the lint job so tests only run after linting passes.

Add Test and Deploy Jobs with Dependencies

Your lint job is running successfully in the cloud, catching style issues before they make it into your codebase. But linting alone doesn't tell you whether your code actually works.

In this step, you'll add two more jobs to your pipeline. A test job runs your pytest suite, and a deploy job simulates a production release. You'll wire them together with dependencies so each stage gates the next.

In this step, get ready to:
  • Add a test job that depends on the lint job passing.
  • Add a deploy job that only runs on pushes to main.
  • Observe how a failing job blocks all downstream jobs.
Add the test job to your workflow

Right now your pipeline has one job. A real pipeline chains multiple jobs together so that expensive operations only run when cheaper checks pass first.

  • In VS Code, open .github/workflows/ci.yml.
  • Find the last line of the lint job (run: flake8 app.py tests/ --max-line-length=120).
  • Add the following test job below it, keeping the same indentation level as lint::
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run tests
        run: python -m pytest tests/ -v

What does this code do?

  • needs: lint creates a dependency. The test job only starts after the lint job completes successfully.
  • The job installs your project dependencies from requirements.txt, which includes Flask and pytest.
  • python -m pytest tests/ -v runs all tests in the tests/ folder with verbose output so you can see each test name and result.
  • Save ci.yml. You won't see a change yet. The next chunk adds the deploy job, then you'll push everything together.
Add the deploy job with branch conditions

In production, you don't want every pull request to trigger a deployment. The deploy job uses an if condition to run only on pushes to the main branch.

  • Below the test job you just added, add the deploy job at the same indentation level:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - name: Simulate deployment
        run: |
          echo "All checks passed!"
          echo "Deploying application..."
          echo "Deployment complete for commit ${{ github.sha }}"

What does this code do?

  • needs: test means the deploy job only runs after tests pass. Combined with the test job's needs: lint, this creates a chain: lint → test → deploy.
  • if: github.ref == 'refs/heads/main' && github.event_name == 'push' ensures deployment only happens on direct pushes to main, not on pull requests.
  • ${{ github.sha }} inserts the commit hash so you can trace exactly which commit was deployed.

Why use job dependencies instead of one big job?

Splitting work into dependent jobs gives you faster feedback. If linting fails in 10 seconds, you find out immediately instead of waiting for a full test suite to finish first.

It also means each job runs on a fresh machine, which prevents side effects from leaking between stages.

  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

name: CI/CD Pipeline

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

jobs:
  lint:
    name: Lint Code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install flake8
        run: pip install flake8
      - name: Run linter
        run: flake8 app.py tests/ --max-line-length=120

  test:
    name: Run Tests
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run tests
        run: python -m pytest tests/ -v

  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - name: Simulate deployment
        run: |
          echo "All checks passed!"
          echo "Deploying application..."
          echo "Deployment complete for commit ${{ github.sha }}"
Push and break the pipeline

Your workflow file now has three connected jobs. Time to push it to GitHub and see the full pipeline run live.

  • Push your updated workflow to GitHub by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add test and deploy jobs"
git push
  • Open your repository on GitHub and click the Actions tab.
  • Click into the latest workflow run. You should see three jobs listed: Lint Code, Run Tests, and Deploy.
  • Wait for all three to complete with green checkmarks.

What should I see?

The jobs run in sequence because of the needs dependencies. Lint runs first, then test starts after lint passes, then deploy starts after test passes.

If you pushed directly to main, all three jobs should run and pass. The deploy job would be skipped if this were a pull request.

Now let's see what happens when a job fails. You'll introduce a deliberate lint error so you can observe how needs blocks downstream jobs.

  • In VS Code, open app.py.
  • Find this line at the top of the file:
from flask import Flask, jsonify
  • Add import os on a new line directly above it, so the top of your file looks like this:
import os
from flask import Flask, jsonify

Why does this break linting?

Flake8 flags unused imports with error code F401. Since nothing in your code uses os, flake8 reports it as dead code that should be removed.

  • Push the broken code to GitHub by running these commands:
git add app.py
git commit -m "Introduce lint error"
git push
  • Open the Actions tab on GitHub.
  • Click into the latest workflow run. The Lint Code job should show a red X.
  • Notice that Run Tests and Deploy are both skipped entirely. This is the needs keyword in action.

All three jobs still passing?

Make sure you saved app.py before committing. Run git diff to confirm your change is tracked.

Also check that import os is on its own line above the Flask import, not combined on the same line.

help me figure out why my lint job isn't failing after I added an unused import

You've proven that job dependencies work. Time to fix the error and restore your pipeline.

  • In app.py, delete the import os line you just added.
  • Push the fix to GitHub by running these commands:
git add app.py
git commit -m "Fix lint error"
git push
  • In the Actions tab, your latest run should show all three jobs passing with green checkmarks again.

Deploy job skipped even after fixing?

The deploy job only runs on pushes to the main branch. If you're working on a different branch, the if condition skips it intentionally.

Confirm you're on main by running git branch in your terminal. The active branch should have an asterisk next to main.

help me figure out why my deploy job is being skipped in GitHub Actions

Your pipeline now has three stages that gate each other. Clean code passes lint, working code passes tests, and only then does deployment happen. Next up, you'll add a status badge and verify the full flow with a pull request.

Add a Status Badge and Verify the Full Pipeline

Your pipeline is running and gating deployments behind passing checks. The final piece is making that pipeline status visible to anyone who visits your repository.

In this step, you'll add a status badge to your README, create a pull request to observe the pipeline running as a PR check, and verify the complete end-to-end flow.

In this step, get ready to:
  • Add a pipeline status badge to your README.md.
  • Create a pull request and observe the pipeline running as a check.
  • Verify the full pipeline flow from lint to deploy.
Add a status badge to your README

A status badge is a small image that updates in real time to show whether your workflow is passing or failing. Adding one to your README gives instant visibility into your project's health.

  • Create a new file called README.md in the root of your python-cicd-pipeline folder by running:
touch README.md
  • Open README.md in VS Code and paste the following content:
# Python CI/CD Pipeline

![CI/CD Pipeline](https://github.com/[[GITHUB_USERNAME="your-github-username"]]/python-cicd-pipeline/actions/workflows/ci.yml/badge.svg)

A Python Flask API with a complete CI/CD pipeline built with GitHub Actions.

## Pipeline Stages

1. **Lint** - Code quality checks with flake8
2. **Test** - Unit tests with pytest
3. **Deploy** - Deployment (runs only on main branch)

## Local Development

```bash
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install -r requirements.txt
python -m pytest tests/ -v
```

What does this file do?

The ![CI/CD Pipeline](...) line is Markdown image syntax that embeds a live badge from GitHub Actions. The URL follows the pattern https://github.com/OWNER/REPO/actions/workflows/WORKFLOW-FILE/badge.svg and updates automatically whenever your pipeline runs.

The rest of the README documents your pipeline stages and provides quick-start instructions for anyone cloning the project.

Replace your username in the badge URL

Make sure you replace the username placeholder in the badge URL with your actual GitHub username. The badge will not render if the URL does not match your repository.

  • Save README.md.
  • Commit and push your README to GitHub by running these commands:
git add README.md
git commit -m "Add README with pipeline status badge"
git push
  • Open your repository on GitHub in your browser.
  • You should see your README rendered on the repository homepage with a green "passing" badge near the top.

Badge not showing or showing as broken image?

Make sure your GitHub username in the badge URL matches your actual username exactly (case-sensitive). The repository name must also be python-cicd-pipeline and the workflow file must be ci.yml.

If the badge shows "no status" instead of "passing", wait a minute for GitHub to cache the badge from your most recent workflow run.

Why isn't my GitHub Actions status badge rendering?

✔️ Awesome, I've got everything!

Great. Make sure you have saved README.md and pushed to GitHub.

ⓧ I'd like to double check the full code

Here is the complete README.md file:

# Python CI/CD Pipeline

![CI/CD Pipeline](https://github.com/[[GITHUB_USERNAME="your-github-username"]]/python-cicd-pipeline/actions/workflows/ci.yml/badge.svg)

A Python Flask API with a complete CI/CD pipeline built with GitHub Actions.

## Pipeline Stages

1. **Lint** - Code quality checks with flake8
2. **Test** - Unit tests with pytest
3. **Deploy** - Deployment (runs only on main branch)

## Local Development

```bash
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install -r requirements.txt
python -m pytest tests/ -v
```
Create a pull request to observe pipeline checks

When your workflow triggers on pull_request events, GitHub runs the pipeline as a check on the PR. This lets reviewers see at a glance whether the proposed changes pass all quality gates.

  • Create a new branch called test-pipeline by running:
git checkout -b test-pipeline
  • Open app.py and add a comment at the top of the file, below the import line. Find this line:
from flask import Flask, jsonify
  • Add this comment directly below it:
# API version 1.0
  • Save app.py.
  • Commit and push your feature branch by running:
git add app.py
git commit -m "Add version comment"
git push --set-upstream origin test-pipeline

Why a separate branch?

Pull requests compare changes between two branches. By pushing to a branch other than main, you can open a PR targeting main and observe the pipeline running as a PR check.

  • Open your repository on GitHub in your browser.
  • You should see a yellow banner saying your branch had recent pushes. Click Compare & pull request.
  • Enter a title like Test pipeline PR check and click Create pull request.

After creating the PR, scroll down to the checks section at the bottom of the PR page. You should see the CI/CD Pipeline workflow running with your lint and test jobs.

Why does the deploy job not run on PRs?

Your deploy job has the condition if: github.ref == 'refs/heads/main' && github.event_name == 'push'. A pull request event does not satisfy either condition, so GitHub skips the deploy job entirely. This is the standard pattern for gating deployments behind merged code only.

Don't see checks on the PR?

Make sure your workflow file has pull_request: listed under on: with branches: [main]. The PR must target the main branch for the filter to match.

Why aren't my GitHub Actions checks appearing on my pull request?

Verify the full pipeline flow

You have now seen the pipeline run on both a direct push (to main) and a pull request. Time to confirm the complete flow works as expected.

  • Switch back to your main branch locally by running:
git checkout main
  • Open the Actions tab in your GitHub repository.
  • Click on the most recent workflow run triggered by your README push to main.
  • Confirm that all three jobs completed successfully: Lint Code, Run Tests, and Deploy.

The workflow graph shows the dependency chain visually. Lint runs first, test waits for lint, and deploy waits for test. On your PR run, only lint and test executed because the deploy condition excluded non-main pushes.

What is the full pipeline flow?

  • A push to main triggers all three jobs: lint, test, and deploy (in order).
  • A pull request targeting main triggers lint and test only. Deploy is skipped because the condition requires a push event on the main branch.
  • If any upstream job fails, all downstream jobs are automatically skipped. This is the gating behavior created by the needs keyword.

Secret mission

Matrix Testing Across Python Versions

Your pipeline tests against one Python version right now. But what happens when your users run Python 3.12 or 3.13? Real-world projects test across multiple versions simultaneously to catch compatibility issues before they reach production.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses GitHub Actions on the free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • GitHub repository (python-cicd-pipeline).
  • Local project folder (python-cicd-pipeline) with Python virtual environment.
  • GitHub Actions workflows (free tier, no charges).

Keep everything running

No action needed. Choose this if you are still actively building or want to reference this project later.

  • Your GitHub repository stays online and your pipeline continues to run on every push.
  • GitHub Actions is free for up to 2,000 minutes per month, so keeping the repository costs nothing.
  • Your local project folder and virtual environment remain on your machine.

Pause - I'll come back to this later

Shut down running processes to free up memory. Keep your files so you can pick up where you left off.

  • Deactivate your Python virtual environment by running:
deactivate
  • Your GitHub repository and workflow files remain intact for when you return.
  • To resume later, navigate back into your project folder and reactivate the virtual environment.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the GitHub repository:
  • Go to your python-cicd-pipeline repository on GitHub.
  • Click Settings (under your repository name).
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Click I want to delete this repository, read the warnings, then click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.
  • Delete your local project folder by running:

macOS

rm -rf ~/Desktop/python-cicd-pipeline

Windows

rmdir /s /q %USERPROFILE%\Desktop\python-cicd-pipeline

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully automated CI/CD pipeline that validates your code every time you push to GitHub.

You've learned how to:

  • Built a Python Flask API with unit tests that validate endpoint responses and status codes.
  • Created a multi-stage GitHub Actions workflow with lint, test, and deploy jobs that gate deployments behind passing checks.
  • Configured job dependencies and branch-specific conditions to control when each pipeline stage runs.
  • Extended the pipeline with matrix testing across multiple Python versions simultaneously.

Ready to quiz yourself?