CI/CD with GitHub Actions: Complete Guide 2025

THEJORD Team5 min read
githubcicddevopsautomation

GitHub Actions guide: CI/CD workflows, matrix builds, secrets, caching and deploy. Best practices 2025 and ready examples.

CI/CD with GitHub Actions: Complete Guide 2025

Introduction to GitHub Actions

GitHub Actions automates your software development workflows directly in your repository. From continuous integration and deployment to code review automation and scheduled tasks, Actions provides a flexible platform for building robust DevOps pipelines.

This guide covers everything from basic workflow syntax to advanced patterns for production CI/CD.

Core Concepts

Workflows, Jobs, and Steps

  • Workflow: An automated process defined in a YAML file, triggered by events
  • Job: A set of steps that run on the same runner
  • Step: Individual task that runs commands or actions
  • Action: Reusable unit of code (from marketplace or custom)
  • Runner: Server that executes your workflows

Workflow Structure

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

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test

Triggers (Events)

Common Triggers

on:
  # Push to specific branches
  push:
    branches: [main, develop]
    paths:
      - 'src/**'
      - '!src/**/*.md'  # Ignore markdown files

  # Pull requests
  pull_request:
    types: [opened, synchronize, reopened]

  # Scheduled (cron)
  schedule:
    - cron: '0 0 * * *'  # Daily at midnight UTC

  # Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

  # On release
  release:
    types: [published]

  # Workflow completion
  workflow_run:
    workflows: [Build]
    types: [completed]

Path and Branch Filters

on:
  push:
    branches:
      - main
      - 'release/**'
      - '!release/old-*'  # Exclude pattern
    paths:
      - 'apps/api/**'
      - 'packages/shared/**'
    paths-ignore:
      - '**/*.md'
      - 'docs/**'

Jobs and Runners

Runner Types

jobs:
  linux:
    runs-on: ubuntu-latest  # or ubuntu-22.04

  macos:
    runs-on: macos-latest   # or macos-13

  windows:
    runs-on: windows-latest # or windows-2022

  self-hosted:
    runs-on: [self-hosted, linux, x64]

Job Dependencies

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

  test:
    needs: build  # Wait for build to complete
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  deploy:
    needs: [build, test]  # Wait for both
    runs-on: ubuntu-latest
    steps:
      - run: npm run deploy

Matrix Builds

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18
        include:
          - os: ubuntu-latest
            node: 22
            experimental: true
      fail-fast: false  # Continue other matrix jobs if one fails

    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm test

Steps and Actions

Using Marketplace Actions

steps:
  # Checkout repository
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0  # Full history for versioning

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

  # Setup Python
  - uses: actions/setup-python@v5
    with:
      python-version: '3.12'

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

Running Commands

steps:
  - name: Install dependencies
    run: npm ci

  - name: Multi-line command
    run: |
      echo "Building..."
      npm run build
      echo "Done!"

  - name: With working directory
    run: npm test
    working-directory: ./apps/web

  - name: With environment variables
    run: npm run deploy
    env:
      API_KEY: ${{ secrets.API_KEY }}
      NODE_ENV: production

  - name: Continue on error
    run: npm run optional-task
    continue-on-error: true

Environment Variables and Secrets

Setting Variables

# Workflow level
env:
  NODE_ENV: production
  CI: true

jobs:
  build:
    # Job level
    env:
      DATABASE_URL: postgres://localhost/test

    steps:
      - name: Build
        # Step level
        env:
          API_KEY: ${{ secrets.API_KEY }}
        run: npm run build

Using Secrets

# Access repository secrets
- name: Deploy
  env:
    DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
  run: ./deploy.sh

# GitHub token (auto-generated)
- name: Create release
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: gh release create v1.0.0

Dynamic Variables

steps:
  - name: Set output
    id: vars
    run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

  - name: Use output
    run: echo "Short SHA is ${{ steps.vars.outputs.sha_short }}"

  - name: Set environment variable
    run: echo "VERSION=1.2.3" >> $GITHUB_ENV

  - name: Use env var
    run: echo "Version is $VERSION"

Conditions and Expressions

jobs:
  deploy:
    # Run only on main branch
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        if: github.event_name == 'push'
        run: ./deploy-prod.sh

      - name: Deploy to staging
        if: github.event_name == 'pull_request'
        run: ./deploy-staging.sh

      # Always run (even if previous steps failed)
      - name: Cleanup
        if: always()
        run: ./cleanup.sh

      # Only on failure
      - name: Notify on failure
        if: failure()
        run: ./send-alert.sh

      # Complex conditions
      - name: Conditional step
        if: |
          github.event_name == 'push' &&
          contains(github.event.head_commit.message, '[deploy]')
        run: ./deploy.sh

Artifacts and Caching

Upload/Download Artifacts

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

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

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - run: ./deploy.sh

Dependency Caching

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

  # Or use built-in caching with setup-node
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'

Docker and Container Actions

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

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 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 }}

Container Jobs

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:20-alpine
      credentials:
        username: ${{ secrets.REGISTRY_USER }}
        password: ${{ secrets.REGISTRY_TOKEN }}

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:postgres@postgres:5432/test

Reusable Workflows

Creating Reusable Workflow

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

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      deploy-key:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.deploy-key }}

Calling Reusable Workflow

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  deploy-staging:
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
    secrets:
      deploy-key: ${{ secrets.STAGING_KEY }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
    secrets:
      deploy-key: ${{ secrets.PRODUCTION_KEY }}

Security Best Practices

  • Never hardcode secrets in workflows
  • Use environment protection rules for production
  • Pin action versions to specific SHA (not just tag)
  • Review third-party actions before using
  • Use CODEOWNERS to protect workflow files
  • Enable required reviews for workflow changes
# Pin to specific commit SHA for security
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Debugging Workflows

steps:
  # Enable debug logging
  - name: Debug info
    run: |
      echo "Event: ${{ github.event_name }}"
      echo "Ref: ${{ github.ref }}"
      echo "SHA: ${{ github.sha }}"
      echo "Actor: ${{ github.actor }}"

  # Dump context for debugging
  - name: Dump GitHub context
    env:
      GITHUB_CONTEXT: ${{ toJSON(github) }}
    run: echo "$GITHUB_CONTEXT"

For validating workflow YAML syntax, use our JSON/YAML tools and Diff Checker to compare workflow versions.

Complete CI/CD Example

name: CI/CD

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

env:
  NODE_VERSION: '20'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          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: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage
          path: coverage/

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    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
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Conclusion

GitHub Actions provides a powerful, integrated CI/CD platform that scales from simple automation to complex deployment pipelines. By mastering workflows, reusable components, and security practices, you can automate your entire software delivery process.

Key takeaways:

  • Start simple and add complexity as needed
  • Use caching to speed up workflows
  • Create reusable workflows for common patterns
  • Protect secrets and use environment approvals
  • Pin action versions for security

For more developer resources, explore our free online tools. For official documentation, see GitHub Actions docs and the Actions Marketplace.