GitHub Actions has become the go-to CI/CD platform for modern development teams, offering powerful automation directly integrated with your code repositories. This comprehensive guide covers everything from basic workflows to advanced patterns used in production.

Why GitHub Actions?

GitHub Actions provides several advantages:

  • Native Integration: Built directly into GitHub
  • Free Tier: 2,000 minutes/month for private repos, unlimited for public
  • Marketplace: Thousands of pre-built actions
  • Matrix Builds: Test across multiple environments simultaneously
  • Self-Hosted Runners: Run on your own infrastructure
  • Secrets Management: Secure credential storage
  • Reusable Workflows: DRY principle for CI/CD

Core Concepts

Workflows

YAML files in .github/workflows/ that define automation

Events

Triggers that start workflows (push, pull_request, schedule, etc.)

Jobs

Groups of steps that run on the same runner

Steps

Individual tasks that run commands or actions

Actions

Reusable units of code (from Marketplace or custom)

Runners

Servers that execute workflows (GitHub-hosted or self-hosted)

Basic Workflow Structure

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

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

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run tests
      run: npm test
    
    - name: Build application
      run: npm run build

Advanced Triggers

on:
  # Multiple events
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - 'docs/**'
      - '**.md'
  
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main
  
  # Schedule (cron)
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM UTC
  
  # Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy'
        required: true
        type: choice
        options:
          - development
          - staging
          - production
      version:
        description: 'Version to deploy'
        required: true
        type: string
  
  # Release published
  release:
    types: [published]
  
  # Issue/PR events
  issues:
    types: [opened, labeled]
  
  # Workflow call (reusable)
  workflow_call:
    inputs:
      config-path:
        required: true
        type: string

Matrix Strategy

Test across multiple versions and platforms:

name: Matrix Build

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [16, 18, 20]
        include:
          - os: ubuntu-latest
            node-version: 18
            experimental: false
          - os: ubuntu-latest
            node-version: 21
            experimental: true
        exclude:
          - os: windows-latest
            node-version: 16
      fail-fast: false
      max-parallel: 4
    
    continue-on-error: ${{ matrix.experimental || false }}
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Setup Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run tests
      run: npm test
    
    - name: Upload coverage
      if: matrix.os == 'ubuntu-latest' && matrix.node-version == '18'
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage/lcov.info

Docker Build and Push

name: Docker Build and Push

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    
    permissions:
      contents: read
      packages: write
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
    
    - name: Log in to Container Registry
      uses: docker/login-action@v3
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    
    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=semver,pattern={{version}}
          type=semver,pattern={{major}}.{{minor}}
          type=semver,pattern={{major}}
          type=sha,prefix={{branch}}-
    
    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        context: .
        platforms: linux/amd64,linux/arm64
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
        cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Kubernetes Deployment

name: Deploy to Kubernetes

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: $
        aws-region: us-east-1
    
    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v2
    
    - name: Build and push Docker image
      env:
        ECR_REGISTRY: $
        IMAGE_TAG: $
      run: |
        docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
        docker push $ECR_REGISTRY/myapp:$IMAGE_TAG
        echo "image=$ECR_REGISTRY/myapp:$IMAGE_TAG" >> $GITHUB_OUTPUT
    
    - name: Configure kubectl
      run: |
        aws eks update-kubeconfig --name my-cluster --region us-east-1
    
    - name: Deploy to Kubernetes
      run: |
        kubectl set image deployment/myapp myapp=$/myapp:$
        kubectl rollout status deployment/myapp

Reusable Workflows

Callable Workflow

# .github/workflows/reusable-build.yml
name: Reusable Build Workflow

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '18'
      environment:
        required: true
        type: string
    secrets:
      deploy-token:
        required: true
    outputs:
      build-id:
        description: "Build identifier"
        value: $

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      build-id: $
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: $
    
    - name: Install and build
      run: |
        npm ci
        npm run build
    
    - name: Generate build ID
      id: build
      run: echo "id=build-$" >> $GITHUB_OUTPUT

Calling the Workflow

# .github/workflows/deploy-staging.yml
name: Deploy to Staging

on:
  push:
    branches: [develop]

jobs:
  build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      node-version: '18'
      environment: 'staging'
    secrets:
      deploy-token: $
  
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
    - name: Deploy
      run: |
        echo "Deploying build: $"

Composite Actions

Create custom reusable actions:

# .github/actions/setup-app/action.yml
name: 'Setup Application'
description: 'Install dependencies and setup caching'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '18'
  cache-key:
    description: 'Cache key prefix'
    required: false
    default: 'npm'

outputs:
  cache-hit:
    description: 'Whether cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
    
    - name: Cache dependencies
      id: cache
      uses: actions/cache@v3
      with:
        path: ~/.npm
        key: ${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ inputs.cache-key }}-${{ runner.os }}-
    
    - name: Install dependencies
      shell: bash
      run: npm ci

Use the composite action:

# .github/workflows/test.yml
name: Test

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    
    - name: Setup application
      uses: ./.github/actions/setup-app
      with:
        node-version: '18'
        cache-key: 'test'
    
    - name: Run tests
      run: npm test

Environment Deployments

name: Deploy to Environments

on:
  push:
    branches: [main]

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Deploy to Staging
      run: |
        echo "Deploying to staging..."
        # Deployment commands
  
  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Deploy to Production
      run: |
        echo "Deploying to production..."
        # Deployment commands

Caching Strategies

name: Build with Caching

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    # Cache Node modules
    - name: Cache Node modules
      uses: actions/cache@v3
      with:
        path: ~/.npm
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-node-
    
    # Cache build output
    - name: Cache build
      uses: actions/cache@v3
      with:
        path: dist
        key: ${{ runner.os }}-build-${{ github.sha }}
    
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
        cache: 'npm'  # Built-in caching
    
    - name: Install and build
      run: |
        npm ci
        npm run build

Artifacts

name: Build and Share Artifacts

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Build application
      run: |
        npm ci
        npm run build
    
    - name: Upload build artifacts
      uses: actions/upload-artifact@v3
      with:
        name: build-$
        path: dist/
        retention-days: 7
    
    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: test-results/
  
  deploy:
    needs: build
    runs-on: ubuntu-latest
    
    steps:
    - name: Download artifacts
      uses: actions/download-artifact@v3
      with:
        name: build-$
        path: dist/
    
    - name: Deploy
      run: |
        echo "Deploying artifacts..."

Security Scanning

name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 0'  # Weekly

jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Run Snyk to check for vulnerabilities
      uses: snyk/actions/node@master
      env:
        SNYK_TOKEN: $
      with:
        args: --severity-threshold=high
  
  code-scan:
    runs-on: ubuntu-latest
    
    permissions:
      security-events: write
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v2
      with:
        languages: javascript, python
    
    - name: Autobuild
      uses: github/codeql-action/autobuild@v2
    
    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v2
  
  container-scan:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Build image
      run: docker build -t myapp:$ .
    
    - name: Run Trivy vulnerability scanner
      uses: aquasecurity/trivy-action@master
      with:
        image-ref: myapp:$
        format: 'sarif'
        output: 'trivy-results.sarif'
    
    - name: Upload Trivy results to GitHub Security
      uses: github/codeql-action/upload-sarif@v2
      with:
        sarif_file: 'trivy-results.sarif'

Self-Hosted Runners

name: Self-Hosted Runner

on: [push]

jobs:
  build:
    runs-on: [self-hosted, linux, x64, gpu]
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Build with GPU
      run: |
        echo "Running on self-hosted runner with GPU"
        # GPU-intensive tasks

Setup Self-Hosted Runner

# On your server
mkdir actions-runner && cd actions-runner

# Download latest runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz \
  -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz

# Extract
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz

# Configure
./config.sh --url https://github.com/yourorg/yourrepo --token YOUR_TOKEN

# Install and start service
sudo ./svc.sh install
sudo ./svc.sh start

# Check status
sudo ./svc.sh status

Secrets Management

name: Deploy with Secrets

on: [push]

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    # Organization secret
    - name: Use organization secret
      run: echo $
    
    # Repository secret
    - name: Use repository secret
      run: echo $
    
    # Environment secret
    - name: Deploy to production
      environment: production
      run: |
        echo $
    
    # Azure Key Vault
    - name: Get secrets from Azure Key Vault
      uses: Azure/get-keyvault-secrets@v1
      with:
        keyvault: "myKeyVault"
        secrets: 'mySecret'
      id: azureSecrets
    
    - name: Use Azure secret
      run: echo $

Conditional Execution

name: Conditional Workflow

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Run on main branch only
      if: github.ref == 'refs/heads/main'
      run: echo "Main branch"
    
    - name: Run on PR only
      if: github.event_name == 'pull_request'
      run: echo "Pull request"
    
    - name: Run on specific actor
      if: github.actor == 'dependabot[bot]'
      run: echo "Dependabot update"
    
    - name: Run based on file changes
      id: changes
      uses: dorny/paths-filter@v2
      with:
        filters: |
          src:
            - 'src/**'
          tests:
            - 'tests/**'
    
    - name: Build if source changed
      if: steps.changes.outputs.src == 'true'
      run: npm run build
    
    - name: Test if tests changed
      if: steps.changes.outputs.tests == 'true'
      run: npm test

Status Badges

Add to your README.md:

![CI](https://github.com/username/repo/actions/workflows/ci.yml/badge.svg)
![Deploy](https://github.com/username/repo/actions/workflows/deploy.yml/badge.svg?branch=main)

Best Practices

Use specific action versions (uses: actions/checkout@v4)
Cache dependencies to speed up workflows
Use matrix builds for multi-platform testing
Implement security scanning in CI pipeline
Use reusable workflows for DRY principles
Set appropriate timeouts to prevent hanging jobs
Use environments for deployment approvals
Leverage composite actions for repeated steps
Monitor workflow costs and optimize runtime
Use concurrency groups to cancel outdated runs

Concurrency Control

name: Deploy

on: [push]

concurrency:
  group: $-$
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Deploy
      run: echo "Deploying..."

Troubleshooting

name: Debug Workflow

on: [push]

jobs:
  debug:
    runs-on: ubuntu-latest
    
    steps:
    - name: Dump GitHub context
      run: echo '${{ toJSON(github) }}'
    
    - name: Dump job context
      run: echo '${{ toJSON(job) }}'
    
    - name: Dump runner context
      run: echo '${{ toJSON(runner) }}'
    
    - name: Enable debug logging
      run: echo "::debug::This is a debug message"
    
    - name: Set step output
      id: test
      run: echo "result=success" >> $GITHUB_OUTPUT
    
    - name: Use step output
      run: echo "Result was ${{ steps.test.outputs.result }}"

Conclusion

GitHub Actions provides a powerful, flexible platform for CI/CD automation. By leveraging reusable workflows, matrix builds, and the extensive marketplace, you can create sophisticated pipelines that improve code quality, accelerate deployments, and enhance team productivity.

Resources


What’s your favorite GitHub Actions workflow? Share in the comments!

Continue Reading