By Aduragbemi David Ogundijo, DevOps Engineer at Venn Innovation
If you have ever shipped code on a Friday afternoon, you know the feeling. You push your change, cross your fingers, and refresh the site to see if anything caught fire. Maybe you remembered to run the tests. Maybe you didn't. Maybe a teammate pushed something an hour ago that quietly broke the build, and now you cannot tell whose change is the problem.
CI/CD exists to delete that feeling.
By the end of this article you will have a real pipeline that runs your tests automatically every single time anyone pushes code. You will see the green checkmark appear, you will watch a broken test get caught before it reaches anyone, and you will understand every line of the file that makes it happen. No prior DevOps experience needed. If you can use Git and run your tests on your own machine, you are ready.
We build it together, step by step, against a small project I have prepared for you.
What CI/CD actually means
The letters get thrown around like everyone already knows them, so here is the plain version.
CI is Continuous Integration. Every time someone pushes code, an automated system pulls it in, builds it, and runs the tests. The word "continuous" just means it happens on every change, not once a week when someone remembers. The payoff: if your change breaks something, you find out in two minutes, not two days, and you find out while the change is still fresh in your head.
CD is Continuous Delivery (or Deployment). Once the tests pass, the same system can take that code and ship it: to a staging server, or all the way to production. Delivery means the code is always ready to ship at the push of a button. Deployment means it ships automatically with no button at all.
Put them together and you have a conveyor belt. Code goes in one end. Tested, deployable software comes out the other. Today we build the CI half, because that is the part that saves you pain immediately and everything else stacks on top of it.
A quick mental model before we touch code. Right now, "run the tests" lives in your head and your fingers. You remember to type pytest because you are disciplined. CI takes that responsibility out of human memory and bolts it onto the repository itself. The repo now refuses to let anyone forget. That shift, from discipline to automation, is the single biggest idea in DevOps. Everything else is variations on it.
What you need
Before you start, make sure you have Git and Python 3 on your machine and a free GitHub account. That is the whole list.
You do not need a project of your own to follow along. I made a tiny starter repo so you can do every step in this article for real, not just read it.
Grab the starter project here: github.com/gbemidijo/devops-ci-starter
Click the green Use this template button (or Fork) to get your own copy under your GitHub account, then clone it (swap in your own username):
git clone https://github.com/YOUR-USERNAME/devops-ci-starter.git
cd devops-ci-starter
Inside you get five small calculator functions and six tests. The code is deliberately boring so all your attention goes to the pipeline. Notice what is not in there: any pipeline. Adding that is the whole exercise, and you are about to do it yourself.
First, run the tests once on your own machine to confirm the project works before we automate anything:
pip install -r requirements.txt
pytest
You should see 6 passed. Now you have a real, working project to build the pipeline around.
If that install is blocked with an "externally managed environment" message (common on recent macOS and Linux), create a quick virtual environment first, then try again:
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install -r requirements.txt
pytest
Two other things worth knowing: you do not need a server, and you do not need to install anything beyond Python. GitHub runs the pipeline for you on its own machines. For public repositories like this one it is completely free with no minute limits. (Private repos get 2,000 free minutes a month, still plenty for a project this size.) The whole article takes about 15 minutes.
Step 1: The one file that runs everything
GitHub looks for pipeline instructions in a special folder inside your repository: .github/workflows. Any file you put there gets treated as a pipeline. The folder name matters exactly, including the leading dot. In your local clone, using your normal editor, create this file:
.github/workflows/ci.yml
Then paste this in:
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get the code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run the tests
run: pytest
That is a complete, working pipeline. Before you commit it, let me walk you through every line, because once you can read this file you can bend it to do almost anything.
Step 2: Read the file line by line
This file is written in YAML, a format built for configuration. YAML cares about indentation the way Python does. Two spaces of indent means "this belongs to the thing above it." Tabs will break it, so use spaces. That one rule causes most beginner YAML errors, so keep it in mind.
Now the content.
name: CI is just a label. It shows up in the GitHub interface so you can tell your pipelines apart when you have more than one.
on: push is the trigger. This single line answers the question "when should this run?" Here it says "on every push." You can make triggers far more specific later, for example only on pushes to main, only on pull requests, or on a nightly schedule. The trigger is the heart of automation, because it decides what event wakes the pipeline up.
jobs: is the list of work to do. A pipeline can have several jobs, and jobs can run side by side on separate machines. We have one job, named test.
runs-on: ubuntu-latest picks the machine. GitHub spins up a brand new, empty Linux computer just for this run, then destroys it when the run finishes. Fresh every time. That freshness is a feature, not a detail. It means the tests pass or fail based only on what is in your repository, not on something you happened to install on your laptop three months ago. This is why "works on my machine" stops being an argument once you have CI.
steps: are the actual commands, run from top to bottom. If any step fails, the job stops immediately and turns red. Our four steps say, in order: grab the code, install Python, install the libraries the project needs, then run the tests.
Look at the two different kinds of step.
Steps with uses: pull in a prebuilt action that someone else wrote and published. actions/checkout@v5 knows how to fetch your code onto the fresh machine. actions/setup-python@v6 knows how to install a specific Python version. The @v5 and @v6 are version pins, telling GitHub which release of that action to use. Pinning a version protects you from a future update changing behavior under your feet. (Use the current major versions. Older majors still run but GitHub warns they use an outdated Node runtime.)
Steps with run: are just shell commands, the exact same ones you would type yourself: pip install -r requirements.txt and pytest. Nothing magic. If you can run it in a terminal, you can put it after run:.
That is the whole file. Four steps, two of them borrowed from the community, two of them your own commands. Standing on other people's published work like this is most of what DevOps actually is in practice.
Step 3: Commit, push, and watch it run
Add the new file, commit it, and push:
git add .github/workflows/ci.yml
git commit -m "Add CI pipeline"
git push
Here is what the push looks like in your terminal. The last lines confirm your commit reached GitHub:
The moment that push lands, GitHub notices the new workflow file and starts running it. Open your repository on GitHub and click the Actions tab at the top. Within a few seconds your run appears. While it works you see a spinning yellow dot. When it finishes successfully, you get a green checkmark like this:
Click into that run and you can watch each step execute. Every step gets its own green check and its own timing, so you can see exactly what happened and how long each part took:
Notice there are more entries here than the four steps you wrote. GitHub adds "Set up job" at the start and "Complete job" at the end automatically, and some actions add their own cleanup step (here, "Post Set up Python"). Those are the machine being prepared and tidied up around your steps. You will see them on every run.
Here is the part that quietly changes how you work. From now on, every push to this repo carries that checkmark or a red X right next to the commit. You stop wondering whether the code is safe. The repository tells you. In a moment we will extend that same signal to pull requests.
Step 4: Break a test on purpose
Do this once, because seeing the failure is what makes the whole thing click into place.
Open test_calculator.py and break one test deliberately. Change the test_add assertion so the math is wrong:
def test_add():
assert add(2, 2) == 5 # deliberately wrong, should be 4
Commit and push that:
git add test_calculator.py
git commit -m "Break a test on purpose"
git push
Go back to the Actions tab and watch the run turn red. Click into the failed step and GitHub shows you the exact test that failed and the error message, the same output you would see running pytest on your own machine:
Read what it is telling you. It found the failing test (test_add), showed the line that failed (assert add(2, 2) == 5), showed the actual versus expected values (assert 4 == 5), and ended the whole run with a non-zero exit code, which is what turns the pipeline red. Now fix the assertion back to 4, push again, and watch it return to green. That loop, break it and have the pipeline catch it for you, is the entire point. The machine is now doing the worrying so you do not have to.
Step 5: Make the check actually protect you
A green check you can ignore is just decoration. The real power comes from making it a gate. Two upgrades, both worth doing today.
First, run the pipeline on pull requests too, not only direct pushes. Change one line in your ci.yml:
on: [push, pull_request]
Now every pull request runs the tests and shows the result right on the PR, so a reviewer sees whether the change is safe before merging:
Second, require that check to pass before anyone can merge. In your repository, go to Settings, then Branches (on newer GitHub this section may be called Rules). Add a rule for main, turn on "Require status checks to pass," and then select your check by name. It shows up as test in the list, but only after the pipeline has run at least once, so push first, then add the rule. Once that is on, broken code physically cannot reach your main branch, not even by accident, not even from you. The rule does not care who you are. That is exactly what you want.
The same pipeline, in Node.js
The concept does not care about your language. Here is the identical pipeline for a Node.js project, so you can see the shape is the same and only the tool names change:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get the code
uses: actions/checkout@v5
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run the tests
run: npm test
Same five ideas: trigger, job, machine, steps, and two borrowed setup actions. Swap setup-python for setup-node, swap pip install for npm ci, swap pytest for npm test. Learn the pattern once and it transfers to Go, Ruby, Java, anything. The pipeline is a recipe, and you are just changing the ingredients.
One caveat on that Node example: npm ci only works if you have committed a package-lock.json (it installs exactly what the lockfile pins, which is what you want in CI). If your project does not have a lockfile yet, use npm install instead.
One upgrade worth knowing about: testing many versions at once
Here is a taste of where this goes. Say you want to be sure your code works on three different Python versions. You do not write three pipelines. You add a matrix:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt
- run: pytest
GitHub now runs your tests three times in parallel, once per version, each on its own fresh machine. You do not need this on day one. I show it so you can see that the file you just wrote is the small version of something that scales a very long way.
What you just learned
You built a system that tests every change automatically, shows everyone whether the code is safe to merge, and can block broken code from reaching your main branch. That is real Continuous Integration, the same concept running at companies with thousands of engineers. Their pipelines have more steps and more polish, but the idea on this page is the idea they use every day.
You also learned to read a workflow file, which means you are no longer copying YAML you do not understand. You can change the trigger, add steps, swap languages, and explain to a teammate exactly what each line does.
Your next step
Pick one of these and do it this week, while the setup is fresh:
-
Add a second check. Most teams run a linter next to their tests to catch style and obvious bugs. Add a step like
run: flake8 .(Python) orrun: npm run lint(Node) and watch it become part of the gate. -
Put a status badge on your README. GitHub generates one for every workflow. Add
to the top of your README so the green or red state shows on your project's front page. -
Make runs faster with caching.
actions/setup-pythoncan cache your installed packages between runs. Addcache: "pip"under itswith:block and watch the install step speed up on the next push.
Next week we go one layer deeper into the workflow that feeds this pipeline: Git branches, merges, and pull requests that do not step on each other. The pipeline you built today is the safety net. Next week is about how a team actually moves code into that net without chaos.
Building something with this? Hit a snag? Leave a comment. I read all of them, and the good questions become future articles.
About the author
Aduragbemi David Ogundijo is a DevOps Engineer at Venn Innovation. He writes a weekly series breaking down practical DevOps for engineers leveling up into the field, one buildable skill at a time. Got a topic you want covered next? Leave it in the comments.





Top comments (0)