Your First GitHub Actions CI Pipeline

Build a CI pipeline that automatically tests Python code on every push.

Introduction

30 Second Summary

Every time developers change their code, they risk breaking something that used to work. Catching those breaks before they reach users is one of the most valuable skills in software engineering.

In this project, you will build an automated pipeline that tests your Python code every time you push it to GitHub. You will write functions, write tests, and watch a cloud server run everything for you.

What You'll Build

A fully automated CI/CD pipeline using GitHub Actions that runs your linter and tests on every push to GitHub.

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

  • A forked Python repository with your own functions and automated tests.
  • A workflow file that triggers a cloud pipeline on every push.
  • First-hand experience watching a pipeline catch a bug and then verifying your fix.
  • Secret Mission: Build and upload a distributable Python package as a downloadable GitHub artifact.

Are there any prerequisites?

None. This project includes full setup instructions for Git, Python, and a code editor. A free GitHub account is all you need to get started.

Set Up Your Environment

Every CI pipeline needs a codebase to automate. In this step, you'll grab a starter Python project that already has functions and tests ready to go.

You'll fork the repository to your own GitHub account, set up your local development tools, and explore the files you'll be automating in later steps.

In this step, get ready to:
  • Fork and clone the starter repository to your local machine.
  • Install and configure your development tools.
  • Explore the existing project files.
Fork and clone the starter repository

What is forking?

Forking creates your own personal copy of someone else's repository on GitHub. The copy lives under your account, so you can make changes freely without affecting the original project.

Think of it like photocopying a recipe. You get the full recipe to modify however you like, while the original stays untouched.

  • Open your browser and go to the starter repository: https://github.com/nextwork-projects/ai-cicd-github.
  • Sign into your GitHub account. If you don't have one, click Sign up and create a free account.
  • Click the Fork button in the top-right corner of the repository page.
  • Leave all defaults as they are and click Create fork.

GitHub redirects you to your new forked repository. The URL now shows your username instead of nextwork-projects.

What is cloning?

Cloning downloads a full copy of a repository from GitHub to your computer. This gives you all the files, folders, and version history locally so you can edit and run the code.

If forking is photocopying the recipe to your own notebook, cloning is taking that notebook home so you can cook with it.

Now you'll clone the fork to your local machine. First, confirm that Git is installed by opening a terminal and running:

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Git installation by running this command:
git --version

✔️ I see a version number

Git is ready to go. Move on to the clone step below.

ⓧ Command not found

You need to install Git first.

  • Download Git from https://git-scm.com/downloads.
  • Run the installer and accept all defaults.
  • Close and reopen your terminal, then run git --version again to confirm.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

cd stands for "change directory." It moves your terminal session into a different folder. ~/Desktop is shorthand for the Desktop folder inside your home directory.

  • Clone your forked repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/ai-cicd-github.git

Where do I find my clone URL?

On your forked repository page, click the green Code button. Copy the HTTPS URL shown there. It looks like https://github.com/YOUR-USERNAME/ai-cicd-github.git.

  • Move into the cloned project folder by running:
cd ai-cicd-github

What does this command do?

This moves your terminal into the freshly cloned project folder. All subsequent commands (installing dependencies, running tests) now run against this repository's files.

  • Confirm the files are there by running:
ls

You should see app.py, requirements.txt, pyproject.toml, and a tests/ folder in the output.

Don't see any files?

Make sure you ran cd ai-cicd-github after cloning. If you see a "no such directory" error, the clone may have failed. Check that you used your fork's URL (with your username), not the original repository URL.

Help me troubleshoot my git clone

Set up your development tools

You need Python to run the project code and a code editor to view and edit files. Let's confirm both are ready.

  • Check your Python installation by running this command:
python3 --version

✔️ I see a version number

Python is installed and ready. Move on to opening Cursor below.

ⓧ Command not found

You need to install Python.

  • Download Python from https://www.python.org/downloads/.
  • Run the installer. On Windows, check the Add Python to PATH checkbox before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version (macOS/Linux) or python --version (Windows) to confirm.

Now open Cursor, the AI-powered code editor you'll use throughout this project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.

✔️ Cursor opened

Great. Now open your project folder in Cursor.

ⓧ I don't have Cursor installed

  • Download Cursor from https://www.cursor.com/.
  • Run the installer and follow the setup prompts.
  • Open Cursor once installation completes.

VS Code works too

If you already have VS Code installed, you can use it instead of Cursor. The steps are identical.

  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the ai-cicd-github folder, then click Open.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).

Your terminal is now open inside the ai-cicd-github project folder. Next, create a virtual environment to isolate your project's dependencies.

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation just for this project. It keeps your project's packages separate from your system Python, so different projects don't conflict with each other.

  • Activate the virtual environment by running this command:

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.

  • Install the project dependencies by running:
pip install -r requirements.txt

What does this install?

The requirements.txt file lists two packages: pytest (a testing framework) and build (a package-building tool used in the Secret Mission).

The linter ruff is installed inline by the CI workflow itself, so you don't need it locally.

You should see pip download and install the packages. Once it finishes, your environment is fully configured.

Seeing a permission error or 'pip not found'?

Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command again.

On some systems, use pip3 instead of pip.

Help me fix my pip install error

Explore the project structure

Before you start building, take a look at what the starter repository gives you. Understanding the existing code helps you know what to add in the next step.

  • In Cursor's Explorer sidebar on the left, click app.py to open it.

You should see three functions already defined: add(), is_even(), and reverse_string(). Each function has type hints and a docstring.

  • Now open tests/test_app.py from the Explorer sidebar.

This file contains test classes for each group of functions: TestMath and TestStrings. Each class groups related test methods together.

How does pytest find tests?

pytest automatically discovers test files that start with test_, classes that start with Test, and methods that start with test_. You don't need any special configuration for this.

  • Finally, open requirements.txt to see the project dependencies.

You should see two packages listed: pytest and build. These are the same packages you just installed into your virtual environment.

Your environment is ready and you know what you're working with. Next up, you'll add your own function and tests to this project.

Write Code and Tests

Your environment is ready and the starter code is in place. Now you get to write real code that your pipeline will eventually test automatically in the cloud.

A CI pipeline is only as useful as the tests it runs. In this step, you will add a new Python function, write automated tests for it using pytest, and confirm everything passes locally before pushing to the cloud.

In this step, get ready to:
  • Add a multiply function to your Python project.
  • Write automated tests for the new function.
  • Run your test suite locally to verify everything passes.
Add a multiply function to app.py

The starter project already has three functions: add(), is_even(), and reverse_string(). You will add a fourth function so that there is new code for the pipeline to test.

  • In Cursor, open app.py from the file explorer sidebar.
  • Scroll to the bottom of the file, below the reverse_string function.
  • Add the following function at the end of the file:
def multiply(a: int, b: int) -> int:
    """Multiply two numbers together."""
    return a * b

What does this code do?

This function takes two integers and returns their product. The a: int, b: int part is a type hint that tells Python (and your linter) what data types the function expects.

The -> int at the end declares what the function returns. Type hints help tools catch bugs before your code even runs.

  • Save app.py.

You will not see a visible change yet. The next substep writes tests that call this function.

Seeing a syntax error?

Make sure the function is placed after the last line of reverse_string with a blank line separating them. Python uses indentation to define function bodies, so the return line must be indented with four spaces.

Help me fix a syntax error in my multiply function in app.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file. Make sure yours matches:

"""Utility functions for the CI starter project."""


def add(a: int, b: int) -> int:
    """Add two numbers together."""
    return a + b


def is_even(n: int) -> bool:
    """Check if a number is even."""
    return n % 2 == 0


def reverse_string(s: str) -> str:
    """Reverse a string."""
    return s[::-1]


def multiply(a: int, b: int) -> int:
    """Multiply two numbers together."""
    return a * b
Write tests for the multiply function

Every function in a CI project needs tests. When your pipeline runs later, it will execute these tests automatically and tell you if anything breaks.

You will update the existing test file to import your new function, then add a test class with three different test cases.

  • Open tests/test_app.py in Cursor.
  • Find the import line at the top of the file:
from app import add, is_even, reverse_string
  • Add multiply to the end of the import list so the line looks like this:
from app import add, is_even, reverse_string, multiply
  • Now scroll to the bottom of the file, below the TestStrings class.
  • Add the following test class at the end of the file:
class TestMultiply:
    """Tests for the multiply function."""

    def test_multiply_positive_numbers(self):
        """Test multiplying two positive numbers."""
        assert multiply(3, 4) == 12

    def test_multiply_by_zero(self):
        """Test multiplying by zero."""
        assert multiply(5, 0) == 0

    def test_multiply_negative_numbers(self):
        """Test multiplying negative numbers."""
        assert multiply(-2, 3) == -6

What does this code do?

Each method inside the TestMultiply class is one test case. pytest automatically discovers any class that starts with Test and any method that starts with test_.

The three cases cover different scenarios: positive numbers, zero (an edge case), and negative numbers. If any assert statement evaluates to False, pytest marks that test as failed.

  • Save tests/test_app.py.

You will not see a change in the browser or terminal yet. The next substep runs the tests to confirm they pass.

Seeing an indentation error?

Make sure the class body is indented with four spaces, and each method body is indented with eight spaces. Also check that there is a blank line separating each method inside the class.

Help me fix an indentation error in my test file.

✔️ 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:

"""Tests for app.py - you'll add more!"""

from app import add, is_even, reverse_string, multiply

class TestMath:
    """Tests for math functions."""

    def test_add_positive(self):
        assert add(2, 3) == 5

    def test_add_negative(self):
        assert add(-1, -1) == -2

class TestStrings:
    """Tests for string functions."""

    def test_reverse(self):
        assert reverse_string("hello") == "olleh"

    def test_is_even(self):
        assert is_even(4) is True
        assert is_even(3) is False

class TestMultiply:
    """Tests for the multiply function."""

    def test_multiply_positive_numbers(self):
        """Test multiplying two positive numbers."""
        assert multiply(3, 4) == 12

    def test_multiply_by_zero(self):
        """Test multiplying by zero."""
        assert multiply(5, 0) == 0

    def test_multiply_negative_numbers(self):
        """Test multiplying negative numbers."""
        assert multiply(-2, 3) == -6
Run pytest to verify all tests pass

Before pushing code to GitHub, you should always run your tests locally first. This catches typos and logic errors before they reach the cloud.

  • Run your full test suite by running this command in your terminal:
pytest -v

What does this command do?

The pytest command finds and runs all test files in your project. The -v flag stands for "verbose" and shows each individual test name and its pass/fail status.

You should see output showing 7 tests, all marked with PASSED in green. The final line should read something like 7 passed.

Tests failing or not found?

  • If you see ModuleNotFoundError: No module named 'app', make sure you are running the command from the project root folder (the one containing app.py), not from inside the tests/ folder.
  • If you see ImportError: cannot import name 'multiply', double check that you saved app.py after adding the function.
  • If you see fewer than 7 tests, make sure your TestMultiply class name starts with a capital T and each method starts with test_.

Help me debug my failing pytest tests.

All 7 tests are passing locally. Next up, you will create a GitHub Actions workflow that runs these same tests automatically in the cloud every time you push code.

Create Your CI Workflow

Your tests pass locally. Every time you run pytest -v, you see 7 green checkmarks. But what happens when you push code and forget to run the tests first?

GitHub Actions solves this by running your linter and tests automatically on a cloud server every time you push. In this step, you'll create a workflow file that tells GitHub exactly what to check.

In this step, get ready to:
  • Create a .github/workflows/ci.yml file that defines your CI pipeline.
  • Push your code to GitHub to trigger the automated workflow.
  • Verify the pipeline passes with a green checkmark in the Actions tab.
Create the CI workflow file

GitHub Actions looks for workflow files in a specific directory: .github/workflows/. Any .yml file in that folder becomes an automated pipeline.

  • Create the required directory structure by running this command:
mkdir -p .github/workflows

What does this command do?

mkdir creates a new directory. The -p flag tells it to create any missing parent directories along the way. You need it here because .github/ doesn't exist yet either.

  • Confirm the directory was created by running:
ls .github/workflows

You should see an empty output with no errors. The directory is ready for your workflow file.

What does this command do?

ls lists the contents of a directory. An empty result (no error) confirms the folder exists but contains no files yet. If the folder didn't exist, you'd see an error message instead.

  • Create a new file called ci.yml inside .github/workflows/ and add the following configuration:
name: CI

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

jobs:
  build:
    runs-on: ubuntu-latest

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

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.11'

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

      - name: Run linter
        run: ruff check .

      - name: Run tests
        run: pytest -v

What does this workflow do?

This YAML file defines an entire automated pipeline. Here is what each section controls:

  • name: CI gives the workflow a human-readable label that appears in the Actions tab.
  • on: defines the triggers. This workflow runs on every push or pull request to the main branch.
  • runs-on: ubuntu-latest tells GitHub to spin up a fresh Ubuntu server (4 CPUs, 16 GB RAM) to run the pipeline.
  • steps: lists the commands to run in order: check out your code, install Python 3.11, install your project's packages, lint with ruff, and run tests with pytest.
  • Save ci.yml.

YAML not looking right?

  • YAML is indentation-sensitive. Each nested level uses exactly 2 spaces, not tabs.
  • Make sure python-version: '3.11' has quotes around the version number. Without quotes, YAML may interpret it as a float.
  • Check that your file is saved at exactly .github/workflows/ci.yml. A typo in the path means GitHub won't find it.

Help me troubleshoot my ci.yml file

✔️ 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

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

jobs:
  build:
    runs-on: ubuntu-latest

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

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.11'

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

      - name: Run linter
        run: ruff check .

      - name: Run tests
        run: pytest -v
Push your code and watch the workflow run

Your workflow is configured to trigger on every push to main. The moment you push your code, GitHub spins up that Ubuntu server and runs your pipeline automatically.

  • Stage, commit, and push all your changes by running these commands:
git add .
git commit -m "Add CI workflow and multiply function"
git push

What do these commands do?

  • git add . stages all changed and new files (your workflow file, updated app.py, and updated test_app.py).
  • git commit -m "..." saves a snapshot of your changes with a message describing what you did.
  • git push uploads your commits to GitHub, which triggers the CI workflow.

You should see output ending with a branch reference and a success message like main -> main.

  • Open your forked repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.

You should see a workflow run listed with your commit message. It may show a yellow spinning icon while it runs. After about 30 seconds, it should turn into a green checkmark.

  • Click on the workflow run to see the details.
  • Click on the build job to expand the step-by-step logs.

You should see each step (Checkout code, Set up Python, Install dependencies, Run linter, Run tests) with a green checkmark next to it. The "Run tests" step shows the same 7 passing tests you saw locally.

What just happened?

GitHub detected your ci.yml file the moment you pushed. It spun up a fresh Ubuntu server, cloned your code, installed Python and your dependencies, ran ruff check . to lint your code, then ran pytest -v to execute your tests.

This entire process happens automatically on every future push. You never have to remember to run tests manually again.

🙋‍♀️ Seeing a red X instead of a green checkmark?

  • Click the failed run and expand the failing step to read the error message.
  • If "Run linter" failed, check that your Python files have no trailing whitespace or unused imports.
  • If "Run tests" failed, confirm all 7 tests pass locally with pytest -v before pushing again.
  • If "Install dependencies" failed, make sure your requirements.txt file is committed and pushed (not just saved locally).

Help me debug my failed GitHub Actions run

Your pipeline is live and running automatically in the cloud. Next up, you'll intentionally break a test to see how the pipeline catches bugs before they reach production.

Break It and Fix It

Your pipeline is passing, which feels great. But the real power of CI/CD is catching bugs automatically before they reach production.

In this step, you will intentionally break a test to prove your pipeline actually catches problems. Then you will fix it and confirm everything goes green again.

In this step, get ready to:
  • Break a test assertion and push to trigger a failed pipeline run.
  • Inspect the failed run logs to identify the exact failure.
  • Fix the broken test and confirm the pipeline passes again.
Intentionally break a test assertion

You are going to change one number in a test so the assertion becomes wrong. This simulates a real-world scenario where a developer accidentally introduces a bug.

  • In tests/test_app.py, scroll down to the TestMultiply class.
  • Find this line inside test_multiply_positive_numbers:
assert multiply(3, 4) == 12
  • Change 12 to 11, so the line now looks like this:
assert multiply(3, 4) == 11

Why break something on purpose?

If your pipeline only ever shows green, you have no proof it actually catches problems. By deliberately introducing a failure, you confirm the safety net is real.

  • Save tests/test_app.py.
  • Commit and push the broken test by running these commands in your terminal:
git add .
git commit -m "break test to verify CI catches failures"
git push
  • Open your forked repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository.
  • You should see a new workflow run with an orange dot (in progress) or a red X (failed).

Don't see a new run?

Make sure your git push completed successfully. Check that your terminal did not show any authentication errors.

Refresh the Actions tab. Workflow runs can take a few seconds to appear.

Help me figure out why my push didn't trigger a workflow run.

Inspect the failed pipeline run

Once the run finishes, the red X tells you something failed. The logs tell you exactly what went wrong and where.

  • Wait for the workflow run to finish. It should show a red X next to the commit message.
  • Click on the failed run (the row with the red X) to open the run summary.
  • Click the build job in the left sidebar.
  • Click the Run tests step to expand its logs.

You should see output showing that test_multiply_positive_numbers failed with an AssertionError. The log shows that multiply(3, 4) returned 12 but the test expected 11.

What do failed pipeline logs tell you?

The logs show the exact test name, the file path, the line number, and the values that didn't match. This is the same output you would see running pytest -v locally.

In a real team, this feedback loop saves hours of debugging. The pipeline catches the problem before broken code gets merged.

Fix the test and push again

Now that you have confirmed the pipeline catches failures, fix the test and push it to restore the green checkmark.

  • In tests/test_app.py, find this line inside test_multiply_positive_numbers:
assert multiply(3, 4) == 11
  • Change 11 back to 12, so the line now looks like this:
assert multiply(3, 4) == 12
  • Save tests/test_app.py.
  • Verify the fix locally by running:
pytest -v

What does this command do?

Re-running the full test suite locally confirms your fix works before you push. This way you catch any remaining issues on your own machine instead of sending a broken commit to the cloud pipeline.

  • You should see all tests passing with a PASSED status.
  • Commit and push the fix by running these commands:
git add .
git commit -m "fix broken test assertion"
git push
  • Go back to the Actions tab on your GitHub repository.
  • Wait for the new run to complete. You should see a green checkmark next to your latest commit.

Still seeing a red X?

Make sure you changed 11 back to 12 (not any other value). Run pytest -v locally first to confirm all tests pass before pushing.

If your local tests pass but the pipeline still fails, check the Run linter step in the logs. A linting error is separate from a test failure.

Help me debug why my pipeline is still failing after I fixed the test.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and that your pipeline shows a green checkmark on GitHub.

ⓧ I'd like to double check the full code

Here is the full tests/test_app.py file with the correct assertion restored:

"""Tests for app.py - you'll add more!"""

from app import add, is_even, reverse_string, multiply

class TestMath:
    """Tests for math functions."""

    def test_add_positive(self):
        assert add(2, 3) == 5

    def test_add_negative(self):
        assert add(-1, -1) == -2

class TestStrings:
    """Tests for string functions."""

    def test_reverse(self):
        assert reverse_string("hello") == "olleh"

    def test_is_even(self):
        assert is_even(4) is True
        assert is_even(3) is False

class TestMultiply:
    """Tests for the multiply function."""

    def test_multiply_positive_numbers(self):
        """Test multiplying two positive numbers."""
        assert multiply(3, 4) == 12

    def test_multiply_by_zero(self):
        """Test multiplying by zero."""
        assert multiply(5, 0) == 0

    def test_multiply_negative_numbers(self):
        """Test multiplying negative numbers."""
        assert multiply(-2, 3) == -6

You just completed the full CI feedback loop: break, detect, fix, verify. Your pipeline is back to green and ready for the Secret Mission.

Secret mission

Build and Upload an Artifact

Your pipeline tests code, but what about shipping it? In this secret mission, you'll add a build step that packages your Python project into a distributable format and uploads it as a downloadable artifact on GitHub.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses GitHub Actions on a public repository, 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:

  • A forked GitHub repository (free for public repos).
  • A Python virtual environment (local, no cost).
  • Project dependencies installed via pip (local, no cost).

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with Part 2 of this series.

  • Your GitHub Actions pipeline only runs when you push code, so it costs nothing while idle.
  • Your virtual environment stays on disk but uses minimal space.
  • Leave everything as-is and come back whenever you're ready.

Pause - I'll come back to this later

There are no ongoing costs to pause. The pipeline only triggers when you push new code to GitHub.

  • Deactivate your virtual environment by running deactivate in your terminal.
  • Your repository and workflow file stay on GitHub, ready for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to clean up completely.

  • Deactivate your virtual environment by running deactivate in your terminal.
  • Delete the local project folder from your Desktop.
  • To delete the forked repo on GitHub, go to your repository page.
  • Click Settings in the top navigation bar.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

That's a Wrap!

That's a Wrap!

You just built your first CI/CD pipeline from scratch. Every time you push code to GitHub, your pipeline automatically lints and tests your Python project on a fresh cloud server.

You've learned how to:

  • Write Python functions with type hints and automated tests using pytest.
  • Configure a GitHub Actions workflow in YAML that triggers automatically on every push to your repository.
  • Watch your pipeline catch a bug in the cloud, then fix it and verify the pipeline goes green.
  • Secret Mission: Built and uploaded a distributable Python package as a downloadable GitHub artifact.

Ready to quiz yourself?