News
🛠️ DevOps Tutorials GitHub Actions: Build Your First CI/CD Pipeline

GitHub Actions: Build Your First CI/CD Pipeline

Automatically test, build, and deploy your code on every push. Set up a working pipeline from scratch without reading the entire Actions documentation.

Every time someone pushes code, GitHub Actions can run your tests, build your app, and deploy it — automatically, in a clean environment, with results posted directly to the pull request. This tutorial builds a working pipeline step by step, explaining what each part actually does.


How it works

Workflows are YAML files in .github/workflows/. GitHub detects them automatically — no registration or webhook setup required. A workflow runs on a fresh virtual machine spun up by GitHub each time it is triggered.

The structure:

name: CI

on: [push]            # when to run

jobs:
  test:               # job name
    runs-on: ubuntu-latest    # VM type

    steps:
      - uses: actions/checkout@v4   # an action (reusable step)
      - run: npm test               # a shell command

That is the whole model. on sets the trigger, jobs defines what to do, steps are the individual commands and reusable actions.


Step 1 — A basic test pipeline

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up 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

Push this file to your repo. GitHub runs it immediately. The actions/checkout@v4 step clones your repo into the VM. actions/setup-node@v4 installs Node and sets up dependency caching so subsequent runs are faster. npm ci is faster and more deterministic than npm install in CI contexts.


Step 2 — Matrix builds

Test against multiple versions with a matrix:

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - run: npm ci
      - run: npm test

This runs three parallel jobs — one per Node version. All three must pass for the workflow to succeed. GitHub shows each run independently in the UI.


Step 3 — Add a build step

      - name: Build
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

upload-artifact saves the build output so a later deployment job can download it without rebuilding from scratch.


Step 4 — A deploy job that runs after tests pass

jobs:
  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
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

  deploy:
    runs-on: ubuntu-latest
    needs: test                      # only runs if test job passes
    if: github.ref == 'refs/heads/main'  # only on pushes to main

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy
        run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

needs: test creates a dependency — the deploy job only starts after test succeeds. if: github.ref == 'refs/heads/main' ensures deploys only happen on the main branch, not on every feature branch push.


Secrets

Never put credentials in workflow files. Store them in your repository's Settings → Secrets and variables → Actions.

Reference them in workflows as ${{ secrets.SECRET_NAME }}. GitHub masks their values in log output automatically.


Caching dependencies

The cache: "npm" option on setup-node handles caching for you. For other scenarios:

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

The cache key includes the lockfile hash — the cache is invalidated exactly when dependencies change, not before or after.


Workflow status badge

Add a status badge to your README:

![CI](https://github.com/your-org/your-repo/actions/workflows/ci.yml/badge.svg)

It shows green when the latest run on the default branch passed, red when it failed.


Common problems

npm ci fails with a lockfile error. The package-lock.json is out of sync with package.json. Run npm install locally, commit the updated lockfile.

Deploy job runs on every branch. Add if: github.ref == 'refs/heads/main' to the deploy job to gate it to the main branch only.

Secrets are not available in pull requests from forks. This is intentional — untrusted code running in a fork cannot access your secrets. Keep secrets out of the test job and limit them to the deploy job, which does not run from forks.