Now that we understand the core concepts and pipeline anatomy, it's time to get hands-on. We'll use the demo app task-tracker-api — a simple REST API for tracking tasks, built with Express, with a handful of Jest tests. The app is deliberately simple, because the focus here isn't the app itself, but the CI/CD pipeline built around it.
The goal of this article: every time we push code or open a pull request, GitHub Actions should automatically install dependencies and run our tests.
Setting Up the Repository
The steps:
demo-app folder in your terminal. cd demo-app
git init
git add .
git commit -m "Initial commit: task-tracker-api"
git branch -M main
gh repo create), then connect and push: git remote add origin https://github.com/<username>/task-tracker-api.git
git push -u origin main
Note that the demo-app folder itself becomes the root of the repository — not the whole course folder. So package.json sits right at the repo root.
Anatomy of a Workflow File
GitHub Actions reads every .yml file inside the .github/workflows/ folder. The filename itself doesn't matter, only the location. Let's build ci.yml piece by piece.
name section:
name: CI
This is just the workflow's name, shown in the Actions tab so it's easy to identify.
The on section — triggers:
on:
push:
branches: [main]
pull_request:
branches: [main]
Remember the trigger concept from the previous article? Here we're saying: run this workflow when there's a push to the main branch, OR when there's a pull request targeting main.
The jobs section — job and step structure:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
A few important parts to understand:
test: — the job's name, which we choose ourselves.runs-on: ubuntu-latest — the runner, a virtual Ubuntu machine provided free by GitHub.steps: — the list of steps that run in order inside this job.uses: vs run: — uses calls a ready-made action from the marketplace (code built by someone else, or by GitHub — e.g. actions/checkout to clone our code onto the runner). run executes a plain shell command.actions/checkout@v4 — REQUIRED in almost every workflow, because the runner starts empty, so we need to "check out" — i.e. fetch — our code first.actions/setup-node@v4 — installs a specific Node.js version on the runner, plus cache: 'npm' to speed up dependency installs on future runs.npm ci — similar to npm install but better suited for CI: installs exactly what's in package-lock.json, faster and more consistent.npm test — the command we already defined in package.json, which runs Jest.This is a pattern you'll see over and over: checkout, set up the runtime, install dependencies, run a command.
Push and Watch the Pipeline Run
git add .github/workflows/ci.yml
git commit -m "Add CI workflow"
git push
Once we push, open the Actions tab on the GitHub repository right away. We'll see a new workflow run appear with a yellow status (running), which then turns green (success) once every step passes.
On the run page, click the run that's currently going, look at the test job, then expand each step to see the real-time log. The duration of each step is shown, and the npm test log shows the exact same Jest output as when it runs locally.
This is the cool part about CI: we get objective, consistent proof that our code works, instead of just trusting someone's "it works on my machine."
Simulating a Pipeline Failure & How to Debug It
Now let's simulate a failure, since this is what tends to panic beginners the most. Try changing an assertion in a test to something wrong:
// Deliberately changed to demo a failure
expect(res.statusCode).toBe(999); // should be 200
git add .
git commit -m "demo: intentionally break a test"
git push
Notice the run is now red (failed). Click on that run, expand the "Run tests" step, and we'll see exactly which Jest assertion failed and at which line. The debugging process is exactly the same as debugging a test locally — the difference is that this saves us from a broken commit getting merged into main.
Once done, revert the change:
git revert HEAD
git push
We revert the broken commit, push again, and the run goes back to green.
Branch Protection — Requiring CI to Pass Before Merge
Finally, let's connect this back to the branching strategy concept from earlier. There's no point having CI if people can still merge a PR while CI is red. That's why we need to enable branch protection.
The steps:
main.test job from our CI workflow.Now, a PR into main can't be merged unless the test job in CI is green. This is what keeps main always in a deployable state, consistent with the trunk-based principle discussed earlier.
Summary
We just built our first CI pipeline: checkout, set up Node, install dependencies, run tests — automatically on every push and PR. We also saw how to read failure logs and how to lock the main branch with branch protection.