Home / Courses / CI/CD for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 3 of 6

Hands-On: Building Your First CI Pipeline with GitHub Actions

A step-by-step walkthrough building a first ci.yml workflow in GitHub Actions — running automated tests on every push and pull request, reading failure logs, and locking down the main branch with branch protection.

July 10, 2026

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:

  • Open the demo-app folder in your terminal.
  • Initialize git and make the first commit:
  •    cd demo-app
       git init
       git add .
       git commit -m "Initial commit: task-tracker-api"
       git branch -M main
       
  • Create a new repository on GitHub (via the web UI or 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.

    The 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:

  • Go to Settings → Branches → Add branch protection rule.
  • Branch name pattern: main.
  • Check "Require status checks to pass before merging", then select the test job from our CI workflow.
  • Save.
  • 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.

    Topics

    CI/CDGitHub Actions