Build a CI/CD Pipeline with GitHub Actions

Automate Python test runs on every push using GitHub Actions.

Introduction

30 Second Summary

Imagine pushing code to your team's project and finding out hours later that you accidentally broke something. Now imagine a robot that checks your code the instant you push it and tells you within seconds whether it works or not.

In this project, you will build a CI/CD pipeline using GitHub Actions that automatically runs your Python tests every time you push code. You will deliberately break your code and watch the pipeline catch it.

What You'll Build

You push a change, walk away, and within seconds your phone buzzes with a green checkmark or a red X telling you exactly what broke and where.

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

  • A pipeline that automatically runs your tests in the cloud every time you push code to GitHub.
  • Proof that automation catches bugs by watching a red X failure appear when you deliberately break your code.
  • The satisfying green checkmark that appears after you fix the bug and push again, completing the DevOps feedback loop.
  • Secret Mission: Add a linting step to your pipeline so it catches style issues alongside test failures.

Are there any prerequisites?

You should be comfortable with basic Python (variables, functions) and basic Git (commit, push). You do not need any prior DevOps experience.

Before We Start

Before we dive in, let's get clear on what you're building and why it matters.

Set Up Your Project

A CI/CD pipeline automatically validates your code every time you push changes. But before you can automate anything, you need actual code worth validating.

In this step, you'll create a Python project with a set of string utility functions and an empty test file. This gives your future pipeline something real to check.

In this step, get ready to:
  • Create a project folder with Git initialized.
  • Write three string utility functions.
  • Verify pytest discovers your project structure.
Create your project folder and initialize Git

Every project starts with a folder and version control. You'll create both, then set up the file structure your pipeline will test later.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ci-cd-pipeline
cd ci-cd-pipeline
  • Initialize a Git repository by running:
git init

You should see a message confirming the repository was initialized, something like Initialized empty Git repository.

  • Create the project's file structure by running these commands:
mkdir src tests
touch src/string_utils.py tests/test_string_utils.py
  • Confirm the files exist by running:
ls src tests

You should see string_utils.py listed under src and test_string_utils.py listed under tests.

What is this project structure?

The src/ folder holds your application code. The tests/ folder holds test files that verify your application code works correctly.

Keeping them separate is a common Python convention. It makes it easy for tools like pytest to find and run your tests automatically.

  • Install pytest (the testing framework) by running:
pip install pytest==9.1.1

You should see output ending with Successfully installed followed by the package name and version.

Seeing a permission error or command not found?

  • If you see a permission error, try running pip install --user pytest==9.1.1 instead.
  • If pip is not found, try pip3 install pytest==9.1.1. Some systems use pip3 for Python 3.

Help me fix my pip install error.

  • Open your project folder in VS Code's file explorer by clicking File in the menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the ci-cd-pipeline folder, and click Open.

You should now see the src and tests folders in the Explorer sidebar on the left.

Write your string utility functions

Your pipeline needs real code to validate. You'll write three small string utility functions that are easy to test.

  • In the Explorer sidebar, click src to expand the folder, then click string_utils.py to open it in the editor.
  • Add your first utility function by pasting this code:
def reverse_string(text):
    """Reverse the characters in a string."""
    return text[::-1]

What does this code do?

  • The function takes a string and returns it backwards.
  • The [::-1] slice is Python shorthand for reversing a sequence.
  • Save string_utils.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Test it by running this in your terminal:
python -c "from src.string_utils import reverse_string; print(reverse_string('hello'))"

You should see olleh printed in your terminal.

Seeing a ModuleNotFoundError?

  • Make sure your terminal is in the ci-cd-pipeline folder. Run pwd to check your current location.
  • If you reopened VS Code's terminal, it may have reset. Run cd ~/Desktop/ci-cd-pipeline to get back.

Help me fix my ModuleNotFoundError.

  • Back in string_utils.py, add two blank lines after your first function, then paste the second function:
def count_vowels(text):
    """Count the number of vowels in a string."""
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)

What does this code do?

  • The vowels variable holds every vowel (uppercase and lowercase) as a lookup string.
  • The sum(1 for char ...) expression counts how many characters from text appear in that lookup.
  • Save the file and test it by running:
python -c "from src.string_utils import count_vowels; print(count_vowels('hello world'))"

You should see 3 printed (the vowels e, o, and o).

  • Add two more blank lines after count_vowels, then paste the third function:
def is_palindrome(text):
    """Check if a string reads the same forwards and backwards."""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

What does this code do?

  • The cleaned variable removes spaces and converts to lowercase so comparisons are fair.
  • It then checks if the cleaned string equals itself reversed. If it does, the word is a palindrome.
  • Save the file and test it by running:
python -c "from src.string_utils import is_palindrome; print(is_palindrome('racecar'))"

You should see True printed. The word "racecar" reads the same forwards and backwards.

✔️ Awesome, I've got everything!

Great. Double check you've saved string_utils.py with all three functions.

ⓧ I'd like to double check the full code

Here is the complete src/string_utils.py file. Make sure yours matches exactly:

def reverse_string(text):
    """Reverse the characters in a string."""
    return text[::-1]


def count_vowels(text):
    """Count the number of vowels in a string."""
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)


def is_palindrome(text):
    """Check if a string reads the same forwards and backwards."""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]
Verify pytest discovers your project

Your code is written and your test file exists (even though it's empty). Time to confirm pytest can find your project structure.

Before you run the next command, what do you think pytest will report? Will it find tests, or will it find nothing?

  • Run pytest in verbose mode:
python -m pytest tests/ -v

You should see output showing that pytest collected 0 items. It discovered the test_string_utils.py file but found no test functions inside it (because the file is still empty).

Why 0 items?

Pytest looks for files starting with test_ and functions starting with test_ inside them. Your file matches the naming convention, so pytest finds it. But since there are no functions inside yet, it collects 0 test items.

In the next step, you'll write test functions that pytest can actually run.

Seeing an error instead of 0 items?

  • Make sure you installed pytest earlier with pip install pytest==9.1.1. Run pytest --version to confirm it's installed.
  • Check that your terminal is in the ci-cd-pipeline folder. The command must run from the project root.

Help me fix my pytest error.

Your project structure is ready and pytest knows where to find your tests. Next up, you'll write the actual test functions that put your utilities through their paces.

Write Tests That Pass Locally

Your string utilities are written, but right now you have no proof they actually work. Automated tests verify that each function produces the correct output for a given input.

In this step, you'll write tests and run them on your own machine. This gives you a known-good baseline to compare against once the cloud takes over testing for you.

In this step, get ready to:
  • Write 6 test functions covering all three string utilities.
  • Run the test suite locally and confirm everything passes.
Write your test functions

pytest uses a simple convention: each test is a function whose name starts with test_. Inside each function, an assert statement checks that the actual output matches what you expect.

  • Switch back to VS Code and open tests/test_string_utils.py.
  • Replace the empty file with the following code:
from src.string_utils import reverse_string, count_vowels, is_palindrome


def test_reverse_string_normal():
    assert reverse_string("hello") == "olleh"


def test_reverse_string_empty():
    assert reverse_string("") == ""


def test_count_vowels_sentence():
    assert count_vowels("hello world") == 3


def test_count_vowels_no_vowels():
    assert count_vowels("gym") == 0


def test_is_palindrome_true():
    assert is_palindrome("racecar") is True


def test_is_palindrome_false():
    assert is_palindrome("hello") is False

What does this code do?

  • The from src.string_utils import ... line pulls in the three functions you wrote in Step 1 so your tests can call them.
  • Each test_ function exercises one scenario: it calls a utility with a specific input and asserts the output matches an expected value.
  • You test two cases per function: one normal input and one edge case (empty string, no vowels, non-palindrome).
  • Save tests/test_string_utils.py.

Seeing a red squiggly under the import?

VS Code may show a warning on the from src.string_utils line because it does not know where src lives. This warning does not affect pytest. Pytest adds the project root to the path automatically when you run it from the project folder.

help me fix import warnings in VS Code for my pytest project

✔️ Awesome, I've got everything!

Great. Double check you have saved your file, then continue to the next substep.

ⓧ I'd like to double check the full code

from src.string_utils import reverse_string, count_vowels, is_palindrome


def test_reverse_string_normal():
    assert reverse_string("hello") == "olleh"


def test_reverse_string_empty():
    assert reverse_string("") == ""


def test_count_vowels_sentence():
    assert count_vowels("hello world") == 3


def test_count_vowels_no_vowels():
    assert count_vowels("gym") == 0


def test_is_palindrome_true():
    assert is_palindrome("racecar") is True


def test_is_palindrome_false():
    assert is_palindrome("hello") is False
Run the tests locally

Your tests are written. Before you run them, think about what you expect to happen.

  • Do you think all 6 tests will pass, or will one fail? Why?
  • Run your tests in verbose mode by running this command in your terminal:
python -m pytest tests/ -v

You should see 6 lines of output, each showing a test name followed by PASSED in green. The final summary line says 6 passed.

What does the verbose output show?

The -v flag tells pytest to print each test on its own line with the result status. Without it, you would just see dots for passing tests and no function names.

Notice that you had to remember to run this command yourself. Every time you change the code, you would need to open the terminal and re-run pytest manually.

Tests failing or not found?

  • Make sure you are running the command from your project's root folder (the one containing both src/ and tests/).
  • Check that test_string_utils.py is inside the tests/ folder, not the project root.
  • Confirm the import line at the top says from src.string_utils (not just from string_utils).

help me debug why my pytest tests are not being found or are failing

Your tests all pass locally. You now have a proven baseline. Next up, you'll push this project to GitHub and create a pipeline that runs these same tests automatically every time you push code.

Push to GitHub and Create Your Pipeline

Your tests pass locally, but there is a catch. You have to remember to run them every single time you change the code.

If you forget even once, broken code reaches your repository unnoticed. A CI pipeline removes that risk by running tests automatically on every push. In this step, you will connect your project to GitHub Actions so the cloud handles testing for you.

In this step, get ready to:
  • Create a public GitHub repository and push your project to it.
  • Write a workflow file that defines your CI pipeline.
  • Watch the pipeline run and confirm a green checkmark in the Actions tab.
Create a GitHub repository and push your project

Your code lives only on your machine right now. You need it on GitHub so that GitHub Actions can access it.

  • Open your browser and go to github.com.
  • In the upper-right corner, click the + menu, then select New repository.
  • Type python-ci-pipeline in the Repository name field.
  • Select Public for the repository visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why leave everything unchecked?

You already have a local Git repository with committed files. If GitHub creates a README or .gitignore for you, the remote and local histories will conflict when you try to push.

GitHub now shows a page with setup commands. You will use those in the next step.

  • Commit all your local files (if you have not already) by running these commands in your terminal:
git add .
git commit -m "Add string utilities and tests"
  • Connect your local repository to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/python-ci-pipeline.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin tells Git where your remote repository lives on GitHub.
  • git branch -M main renames your current branch to main (the branch your pipeline will watch).
  • git push -u origin main uploads your commits to GitHub and sets main as the default upstream branch.
  • Refresh your repository page on GitHub. You should see your src/ and tests/ folders listed with your files inside them.

Push failed or asked for credentials?

  • If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS. Generate a Personal Access Token in Settings > Developer settings > Personal access tokens and use that as your password.
  • If you see "remote origin already exists", run git remote remove origin first, then re-run the git remote add command.
  • Why is my push to GitHub failing?
Create the workflow file

A YAML file in a special directory tells GitHub Actions what to do every time you push code.

  • Create the required directory structure by running this command in your terminal:
mkdir -p .github/workflows
  • Create a new file called test.yml inside .github/workflows/ and paste the following code into it:
name: Run Tests

on:
  push:
    branches: ["main"]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
      - name: Set up Python 3.13
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pytest
      - name: Run tests
        run: pytest tests/ -v

What does this workflow do?

  • on: push: branches: ["main"] triggers the pipeline every time you push to the main branch.
  • runs-on: ubuntu-latest spins up a fresh Linux virtual machine in the cloud for your tests.
  • actions/checkout@v6 pulls your repository code into that virtual machine.
  • actions/setup-python@v5 installs Python 3.13 on the runner.
  • The final step runs pytest tests/ -v exactly like you did locally.
  • Save test.yml.
  • Confirm the file exists by running:
ls .github/workflows/

You should see test.yml in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file, then move on to the next substep.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/test.yml file:

name: Run Tests

on:
  push:
    branches: ["main"]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
      - name: Set up Python 3.13
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pytest
      - name: Run tests
        run: pytest tests/ -v
Push the workflow and watch it run

The workflow file exists locally but GitHub does not know about it yet. Once you push it, GitHub Actions kicks in automatically.

  • Commit and push the workflow file by running these commands:
git add .github/workflows/test.yml
git commit -m "Add CI workflow"
git push

Before you check GitHub, what do you think the Actions tab will show? Will the tests pass or fail?

  • Open your repository page on GitHub.
  • Click the Actions tab at the top of the page.
  • You should see a workflow run labeled Add CI workflow either in progress or already completed.

Within about 30 seconds, the run finishes with a green checkmark. All 6 tests passed in the cloud without you lifting a finger.

Why is this better than running tests locally?

You no longer have to remember to run pytest yourself. Every push triggers the pipeline automatically. If you (or a teammate) ever push broken code, the pipeline catches it before it reaches production.

Don't see a green checkmark?

  • Click into the failed run and read the red error text. The most common cause is a YAML indentation mistake. YAML is whitespace-sensitive, so every space matters.
  • Make sure your file is at exactly .github/workflows/test.yml (not github/workflows/test.yml without the leading dot).
  • Why is my GitHub Actions workflow failing?

Your tests now run automatically every time you push. Next up, you will deliberately break something and watch the pipeline catch it.

Break Something and Watch the Pipeline Catch It

In the last step, you pushed your code and watched the pipeline pass with a green checkmark. That felt good.

But a pipeline that only ever shows green has not proven its value. Anyone can pass a test they never challenge. The real power of CI is catching mistakes automatically, before they reach your teammates. In this step, you will deliberately break your code, push it, and watch the pipeline catch your mistake with a red X.

In this step, get ready to:
  • Introduce a bug into your string utility code.
  • Watch the pipeline detect and report the failure.
  • Fix the bug and see the pipeline turn green again.
Introduce a bug in reverse_string

You are going to sabotage your own code on purpose. This gives you a chance to see exactly what happens when a test fails in the cloud.

  • In VS Code, open src/string_utils.py.
  • Find this line inside the reverse_string function:
return text[::-1]
  • Replace it with return text so the function now looks like this:
def reverse_string(text):
    """Reverse the characters in a string."""
    return text

What does this change do?

The function now returns the original text unchanged instead of reversing it. Your test expects reverse_string("hello") to return "olleh", but the broken code returns "hello". The pipeline should catch this mismatch.

  • Save src/string_utils.py.
  • Commit and push the broken code by running these commands:
git add .
git commit -m "Introduce bug in reverse_string"
git push

Before you check the Actions tab, pause and predict: do you think the pipeline will pass or fail? Why?

Watch the pipeline catch the failure
  • Switch to your browser and go to the Actions tab in your GitHub repository.
  • Wait about 30 seconds. A new workflow run appears with a red X next to your commit message.
  • Click on the failed run name to open the workflow run summary.
  • Under Jobs, click the test job.
  • The failed step (Run tests) is automatically expanded showing the test output.

You can see exactly which test failed and why. The log shows that test_reverse_string_normal expected "olleh" but got "hello".

Why does this matter?

You did not have to remember to run tests. You did not have to check the output yourself. The pipeline caught the bug automatically and told you exactly what went wrong.

In a team, this means broken code never silently reaches the main branch. Everyone gets the same safety net regardless of how careful or forgetful they are.

Fix the bug and see green again

Now you know the pipeline works. Time to restore the correct logic and close the feedback loop.

  • Switch back to VS Code and open src/string_utils.py.
  • Find this line inside the reverse_string function:
return text
  • Replace it with return text[::-1] so the function is back to its correct form:
def reverse_string(text):
    """Reverse the characters in a string."""
    return text[::-1]
  • Save src/string_utils.py.
  • Commit and push the fix by running these commands:
git add .
git commit -m "Fix reverse_string bug"
git push

Before you check the Actions tab again, predict: will the pipeline pass this time?

  • Switch to your browser and go to the Actions tab.
  • Wait about 30 seconds for the new run to complete.

You see a green checkmark next to your latest commit. All 6 tests pass again.

What just happened?

You just experienced the core DevOps feedback loop: push, break, catch, fix, pass. This is the rhythm professional teams rely on every day. The pipeline acts as an automated safety net that gives you immediate, honest feedback on every change.

Still seeing a red X after pushing the fix?

Make sure you saved src/string_utils.py before committing. Run git diff to check if the file still has uncommitted changes.

Confirm that the reverse_string function returns text[::-1] and not text. A typo here means the test still fails.

help me figure out why my pipeline is still failing after I fixed the bug

✔️ Awesome, I've got everything!

Great. Double check you have saved src/string_utils.py with the correct reverse_string logic restored.

ⓧ I'd like to double check the full code

Here is the full src/string_utils.py file as it should look now:

def reverse_string(text):
    """Reverse the characters in a string."""
    return text[::-1]


def count_vowels(text):
    """Count the number of vowels in a string."""
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)


def is_palindrome(text):
    """Check if a string reads the same forwards and backwards."""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

Your pipeline catches bugs automatically and gives you confidence that your code works. Next up, you can take on the Secret Mission and add code quality checks that run alongside your tests.

Secret mission

Add a Linting Step

Your pipeline catches broken logic, but what about messy code style? Add a linter to your workflow so it enforces code quality alongside test correctness on every push.

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:

  • Local project folder on your Desktop containing src/, tests/, and .github/workflows/.
  • GitHub public repository with Actions enabled.
  • pytest installed locally via pip.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your pipeline or want to add more features.

  • Your GitHub repository stays live with its Actions history intact.
  • Your local project folder remains on your Desktop.
  • pytest stays installed in your Python environment.

Pause - I'll come back to this later

Since this project has no running processes or paid cloud resources, pausing is the same as keeping everything.

  • Your GitHub repository and Actions history will remain available whenever you return.
  • Your local files stay on disk until you decide to delete them.
  • No costs accumulate while the project sits idle.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete your GitHub repository: go to your repository page, click the Settings tab, scroll down to the Danger Zone section, and click Delete this repository. Confirm the deletion when prompted.
  • Delete your local project folder by running this command from your Desktop:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • Uninstall pytest from your local Python environment:
pip uninstall pytest

Confirm with y when prompted.

Nice Work!

Nice Work!

You just built a fully automated CI/CD pipeline that tests your Python code every time you push to GitHub. No more forgetting to run tests. No more broken code slipping through.

You've learned how to:

  • Build a Python utility library with automated tests using pytest, and run them locally to establish a passing baseline.
  • Create a GitHub Actions CI pipeline that triggers automatically on every push to your repository.
  • Experience the core DevOps feedback loop: push, break, catch, fix, pass.
  • Secret Mission: Add a parallel linting job to enforce code quality alongside test correctness.

Ready to quiz yourself?