Skip to main content

CI/CD with GitHub Actions

Continuous Integration (CI) automatically tests your code on every push. Continuous Deployment (CD) automatically deploys it when tests pass. GitHub Actions makes this easy with workflow files that live right in your repository.

How GitHub Actions Works

Workflows are defined in YAML files inside .github/workflows/. Each workflow has:

  • Triggers — events that start the workflow
  • Jobs — groups of steps that run on a virtual machine
  • Steps — individual commands or actions

Your First Workflow

Create .github/workflows/ci.yml:

name: CI

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

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

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

This workflow runs on every push to main and every pull request targeting main. It checks out your code, installs dependencies, lints, and runs tests.

Triggers

Common triggers:

on:
  # Run on push to specific branches
  push:
    branches: [main, develop]

  # Run on pull requests
  pull_request:
    branches: [main]

  # Run on a schedule (cron syntax)
  schedule:
    - cron: '0 8 * * 1'  # Every Monday at 8 AM UTC

  # Run manually from the GitHub UI
  workflow_dispatch:

You can also filter by paths to avoid running the workflow when unrelated files change:

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'

Multiple Jobs

Jobs run in parallel by default. Use needs to create dependencies:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    needs: [lint, test]  # Waits for both to pass
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Deploy to production
        run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

The lint and test jobs run in parallel. The deploy job only runs after both succeed, and only on the main branch.

Environment Variables and Secrets

Never hardcode sensitive values. Use GitHub Secrets instead:

  1. Go to your repo on GitHub -> Settings -> Secrets and variables -> Actions
  2. Click "New repository secret"
  3. Add your secret (e.g., VERCEL_TOKEN)

Use them in workflows:

steps:
  - name: Deploy
    run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
    env:
      NODE_ENV: production

For non-sensitive configuration, use environment variables directly:

env:
  NODE_ENV: production
  NEXT_PUBLIC_API_URL: https://api.example.com

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building for $NODE_ENV"

Caching Dependencies

Speed up workflows by caching node_modules:

steps:
  - uses: actions/checkout@v4

  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'

  - run: npm ci

The actions/setup-node action handles caching automatically when you specify the cache parameter. For pnpm:

  - uses: pnpm/action-setup@v4
    with:
      version: 9

  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'pnpm'

  - run: pnpm install --frozen-lockfile

Matrix Builds

Test across multiple versions or operating systems:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This creates 6 parallel jobs — every combination of OS and Node version.

A Full Deployment Pipeline

Here's a real-world workflow for a Next.js app deployed to Vercel:

name: CI/CD Pipeline

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

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

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm test
      - run: pnpm build

  deploy-preview:
    runs-on: ubuntu-latest
    needs: quality
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy preview
        run: npx vercel --token=${{ secrets.VERCEL_TOKEN }}

  deploy-production:
    runs-on: ubuntu-latest
    needs: quality
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

This pipeline lints, tests, and builds on every push and PR. Pull requests get a preview deployment. Pushes to main deploy to production.