GitHub Actions CI/CD Guide — From Workflows to Deployment

What Is GitHub Actions?

GitHub Actions is a CI/CD (Continuous Integration/Deployment) platform built into GitHub. When you push code, tests, builds, and deployments run automatically. Think of it as a factory assembly line: raw materials (code) come in, and quality inspection (tests), assembly (build), and shipping (deployment) happen automatically.

Core Concepts

ConceptDescriptionAnalogy
WorkflowThe entire automation processAssembly line
EventTrigger that starts the workflowStart button
JobUnit of work running on the same runnerWorkstation
StepIndividual command within a jobWork step
RunnerServer that executes the workflowWorker
ActionReusable work unitAssembly part

Writing a Basic Workflow

# .github/workflows/ci.yml
name: CI Pipeline

# Define trigger events
on:
  push:
    branches: [main, develop]   # On push to main or develop
  pull_request:
    branches: [main]            # On PR creation/update targeting main

# Environment variables (available throughout the workflow)
env:
  NODE_VERSION: "22"

jobs:
  # Job 1: Code quality checks
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest      # Run on latest Ubuntu runner
    steps:
      # Check out source code
      - uses: actions/checkout@v4

      # Set up Node.js + caching
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"          # Cache node_modules (speeds up builds)

      # Install dependencies
      - run: npm ci             # ci installs exact versions from lock file

      # Run linter
      - run: npm run lint
        name: ESLint Check

      # Type checking
      - run: npm run typecheck
        name: TypeScript Type Check

  # Job 2: Testing
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: lint                 # Runs only after lint job succeeds
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"
      - run: npm ci
      - run: npm test -- --coverage
        name: Run Tests (with coverage)

      # Upload test coverage results
      - uses: actions/upload-artifact@v4
        if: always()            # Upload even if tests fail
        with:
          name: coverage-report
          path: coverage/

  # Job 3: Build
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test                 # Runs after tests succeed
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"
      - run: npm ci
      - run: npm run build
        name: Production Build

      # Save build artifacts
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7    # Retain for 7 days

Matrix Builds: Test Across Multiple Environments

# Test simultaneously across multiple Node.js versions and OSes
jobs:
  test-matrix:
    name: Test (${{ matrix.os }} / Node ${{ matrix.node-version }})
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]
        exclude:
          # Exclude macOS + Node 18 combination (unnecessary)
          - os: macos-latest
            node-version: 18
        include:
          # Add extra settings for a specific combination
          - os: ubuntu-latest
            node-version: 22
            coverage: true      # Collect coverage only in this combination

      fail-fast: false          # Continue running others even if one fails

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
      - run: npm ci
      - run: npm test
      # Run only in the combination with coverage setting
      - run: npm test -- --coverage
        if: matrix.coverage == true

Secrets and Environment Variables

# Secrets are set in GitHub Settings → Secrets and Variables → Actions

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production     # Enables per-environment secrets

    steps:
      - uses: actions/checkout@v4

      # Using secrets — automatically masked in logs
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}         # Secret
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          APP_ENV: production                     # Regular variable
        run: |
          echo "Deploy environment: $APP_ENV"
          # $API_KEY and $DEPLOY_TOKEN are masked as *** in logs
          ./deploy.sh

Automated Deployment Workflow

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]           # Deploy only on push to main

# Prevent concurrent deployments
concurrency:
  group: production-deploy
  cancel-in-progress: false    # Don't cancel in-progress deployments

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com  # Deployment URL (shown in GitHub)

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci
      - run: npm run build

      # Docker image build & push
      - name: Docker Build and Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

      # Deploy to server via SSH
      - name: Server Deployment
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /app
            docker compose pull
            docker compose up -d --remove-orphans
            echo "Deployment complete: $(date)"

      # Slack notification on success
      - name: Deployment Success Notification
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployment success: ${{ github.repository }} (${{ github.sha }})"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

      # Slack notification on failure
      - name: Deployment Failure Notification
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployment failed: ${{ github.repository }} — investigation needed!"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

PR Automation

# .github/workflows/pr-check.yml
name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  pr-check:
    runs-on: ubuntu-latest

    # Grant permissions to PR (for commenting, etc.)
    permissions:
      contents: read
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci
      - run: npm run build

      # Bundle size comparison comment
      - name: Bundle Size Report
        uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          # Posts bundle size changes as a PR comment

      # Auto-label
      - uses: actions/labeler@v5
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

Speeding Up Builds with Caching

# Caching strategy — reduce dependency install time
steps:
  # npm cache (handled automatically by setup-node)
  - uses: actions/setup-node@v4
    with:
      node-version: "22"
      cache: "npm"

  # Custom cache — build cache, etc.
  - name: Turbo Cache
    uses: actions/cache@v4
    with:
      path: .turbo           # Cache path
      key: turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      restore-keys: |
        turbo-${{ runner.os }}-
Cache TargetImpactConfiguration
npm/yarn/pnpm80% reduction in dependency installsetup-node cache option
Docker layers50% reduction in image builddocker/build-push-action cache-from
Build artifactsIncremental buildsactions/cache for .next/, dist/

Practical Tips

  • Use npm ci instead of npm install: In CI environments, npm ci installs exact versions from the lock file and is faster.
  • Control concurrent runs with concurrency: When pushing multiple times to the same branch, previous runs are automatically cancelled to save resources.
  • Use if: always() and if: failure(): These enable follow-up actions like uploading reports or sending notifications even when tests fail.
  • Reduce duplication with Reusable Workflows: Share common workflows across repositories with uses: org/workflows/.github/workflows/ci.yml@main.
  • Leverage the GitHub Actions Marketplace: Pre-built Actions exist for most tasks. Search before writing custom scripts.
  • Monitor costs: Public repositories are free, but private repositories are billed after 2,000 free minutes per month. Clean up unnecessary workflows.

Was this article helpful?