DEV Community

Sri Balaji
Sri Balaji

Posted on • Originally published at thesimplifiedtech.com

Your First CI Pipeline with GitHub Actions

From theory to a green checkmark

You know what a pipeline does, now let's build one. By the end of this article you'll have a working CI pipeline that runs on every push and every pull request, installs your dependencies, lints your code, runs your tests, and shows a green checkmark (or a red X) right in the GitHub UI. It's free, it ships with every GitHub repo, and you can have it running in ten minutes.

We'll build it up one piece at a time so nothing is a black box. Each code block is a real, working .github/workflows/*.yml file, copy them straight into a repo.

Note: Who this is for: Anyone with a GitHub repo and basic Git who's never written a workflow file. The examples use a Node.js project (npm), but the structure is identical for Python, Go, or anything else, only the install/test commands change. If you've read CI/CD Fundamentals, you have all the background you need.

The vocabulary you need (just five words)

GitHub Actions has its own nouns. Learn these five and the YAML stops looking cryptic. They nest inside each other like Russian dolls.

Term What it is
Workflow One .yml file in .github/workflows/. The whole automated process.
Event What triggers the workflow, a push, a pull_request, a schedule.
Job A group of steps that run together on one machine. Jobs can run in parallel.
Step A single task in a job, run a command, or use a prebuilt action.
Runner The machine a job runs on, e.g. ubuntu-latest, fresh every run.

The five building blocks, biggest to smallest.

Put together: a workflow listens for an event, which kicks off one or more jobs, each running its steps on a runner. That's the entire model.

Step 1, the smallest workflow that does something

Create the file .github/workflows/ci.yml in your repo. The folder path matters exactly, GitHub only looks in .github/workflows/. Start with the absolute minimum: check out the code and print a line.

.github/workflows/ci.yml

name: CI                      # shows up in the Actions tab

on: [push, pull_request]      # the events that trigger it

jobs:
  hello:                       # job id (any name)
    runs-on: ubuntu-latest     # the runner
    steps:
      - uses: actions/checkout@v4   # pull your repo onto the runner
      - run: echo "Pipeline is alive!"
Enter fullscreen mode Exit fullscreen mode

Commit and push it. Open the Actions tab on GitHub and you'll see the run executing live. That's a working pipeline, it just doesn't do anything useful yet. Two things worth understanding now: actions/checkout@v4 is a prebuilt action (someone else's reusable step) that clones your repo onto the runner, and run: executes a shell command. Almost every step is one of those two shapes.

Tip: YAML is whitespace-sensitive, indentation is two spaces, never tabs. About half of all "my workflow won't run" problems are an indentation slip. If GitHub shows a syntax error, check your spacing first.

Step 2, install, lint, and test

Now make it real. We'll set up Node, do a clean dependency install, lint, and run tests, the three checks that should gate every change. Each is just another step.

.github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm ci          # clean install from the lockfile

      - name: Lint
        run: npm run lint

      - name: Run tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices here. We use npm ci, not npm install, ci installs the exact versions from your lockfile and fails if the lockfile is out of sync, which is precisely what you want in automation: reproducible, no surprises. We also narrowed the push trigger to the main branch while keeping pull_request open, so the pipeline runs on every PR and on merges to main, but not on every push to every random feature branch (saving CI minutes).

Warning: In CI, prefer npm ci over npm install. npm install can quietly update your lockfile and pull different versions than your teammates have, defeating the whole point of a reproducible build. npm ci is stricter and faster. (Python: use pip install -r requirements.txt with pinned versions; the principle is the same.)

Step 3, make it fast with caching

Right now every run re-downloads all your dependencies from scratch, slow and wasteful. Caching stores them between runs so repeat builds are dramatically faster. The setup-node action has caching built in; you just turn it on.

.github/workflows/ci.yml (excerpt)

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"        # cache ~/.npm keyed on your lockfile
Enter fullscreen mode Exit fullscreen mode

That single cache: "npm" line keys the cache on your package-lock.json. As long as your dependencies don't change, the cache is reused and npm ci finishes in seconds instead of minutes. When you add or update a package, the lockfile changes, the key changes, and the cache rebuilds automatically. You never manage it by hand.

Tip: Slow pipelines get ignored. If CI takes longer than your coffee refill, people stop watching it and start merging on faith, which defeats the purpose. Caching is the single highest-leverage speedup for most pipelines. Turn it on early.

Step 4, run independent jobs in parallel

Linting and testing don't depend on each other, so why run them one after the other? Split them into separate jobs and GitHub runs them on separate machines simultaneously, your total wall-clock time drops to whichever is slowest, not the sum.

.github/workflows/ci.yml

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

Now lint and test run in parallel. Each job is fully isolated, a fresh runner with nothing shared, which is why each repeats the checkout and install. That isolation is a feature: one job can't pollute another. If you ever need a job to wait for another (say, deploy after tests pass), you add needs: test to it, and GitHub sequences them for you.

Step 5, make the pipeline a required status check

A green checkmark is nice, but the real power move is making it mandatory: configure your repo so a pull request cannot be merged until CI passes. This turns your pipeline from a suggestion into a gate that protects main.

  1. Open branch protection: In your GitHub repo: Settings → Branches → Add branch protection rule. Set the branch name pattern to main.
  2. Require status checks: Tick "Require status checks to pass before merging," then search for and select your jobs (lint and test) from the list. They appear once they've run at least once.
  3. Require a PR: Also tick "Require a pull request before merging" so nobody can push straight to main and skip the checks entirely.
  4. Save and test it: Open a PR that deliberately breaks a test. GitHub now blocks the Merge button until the pipeline is green. That's CI doing its actual job.

Warning: Without branch protection, CI is advisory, a teammate in a hurry can merge a red build. The status check is what makes the pipeline enforced. Set it up the moment your CI is stable; it's the difference between "we have tests" and "broken code can't reach main."

Common mistakes that cost hours

  1. Wrong file location. Workflows only run from .github/workflows/. A file in .github/ or workflows/ is silently ignored, no error, just nothing happens.
  2. Tabs instead of spaces. YAML demands spaces. A single tab anywhere breaks the whole file. Configure your editor to show whitespace.
  3. Using npm install instead of npm ci. install can drift your lockfile and produce non-reproducible builds. ci is the automation-correct choice.
  4. Hardcoding secrets in the YAML. Tokens and passwords in a workflow file land in your Git history permanently. Use repo Settings → Secrets and reference them as ${{ secrets.NAME }}.
  5. Never enabling branch protection. A pipeline that doesn't block merges is decoration. Make it a required status check or broken code will still reach main.
  6. Forgetting the cache. Skipping cache: "npm" makes every run re-download everything. People then stop waiting for slow CI, and a CI nobody watches is worthless.

Takeaways

Your first pipeline in seven lines

  • A workflow lives in .github/workflows/*.yml and is triggered by events.
  • Workflow → jobs → steps → runners. Jobs run in parallel by default.
  • Use actions/checkout to get your code, actions/setup-node to set up the toolchain.
  • Use npm ci (not install) for reproducible builds; add cache: "npm" for speed.
  • Split independent work (lint, test) into separate jobs to run them in parallel.
  • Use needs: to sequence jobs when one must wait for another.
  • Enable branch protection with required status checks so red builds can't merge.

Where to go next

You have CI gating every change. The natural next steps are understanding the bigger picture you just plugged into, practicing the commands hands-on, and adding a build step that packages your app into a container.


Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.

Top comments (0)