Skip to content
Back

Advanced GitHub Actions: Building Efficient CI/CD Pipelines

Zhou Xunyou 7 min read devops
Share

GitHub Actions is one of the most popular CI/CD platforms today, yet many teams only use its most basic features — running tests and building images. This article explores advanced GitHub Actions capabilities including matrix builds, reusable workflows, caching strategies, security best practices, and performance optimization to help you build truly efficient CI/CD pipelines.

Matrix Builds: Configure Once, Verify Everywhere

Matrix strategies let you run tests across multiple environments with a single workflow definition.

Basic Matrix

name: Matrix Build

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false  # One failure doesn't affect others
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
        exclude:
          - os: macos-latest
            node-version: 18  # Don't test Node 18 on macOS
        include:
          - os: ubuntu-latest
            node-version: 22
            experimental: true  # Extra variable
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

Dynamic Matrix: Build Only What Changed

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
            backend:
              - 'backend/**'
            shared:
              - 'shared/**'

  build:
    needs: detect-changes
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect-changes.outputs.packages || '[]') }}
    steps:
      - uses: actions/checkout@v4
      - run: |
          echo "Building ${{ matrix.package }}"
          cd ${{ matrix.package }} && make build

Reusable Workflows

Extract common logic into reusable workflows to avoid duplicating definitions across repositories.

Defining a Reusable Workflow

# .github/workflows/deploy.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
      deploy-url:
        required: false
        type: string
        default: ''
    secrets:
      deploy-key:
        required: true
      registry-token:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4

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

      - name: Deploy
        run: |
          echo "Deploying ${{ inputs.image-tag }} to ${{ inputs.environment }}"
          # Deployment logic...

      - name: Notify
        if: always()
        run: |
          echo "Deployment ${{ job.status }} for ${{ inputs.environment }}"

Calling a Reusable Workflow

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

on:
  push:
    branches: [main]

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

  deploy-staging:
    needs: build
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
      image-tag: ${{ github.sha }}
    secrets:
      deploy-key: ${{ secrets.STAGING_DEPLOY_KEY }}
      registry-token: ${{ secrets.GITHUB_TOKEN }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
      image-tag: ${{ github.sha }}
    secrets:
      deploy-key: ${{ secrets.PROD_DEPLOY_KEY }}
      registry-token: ${{ secrets.GITHUB_TOKEN }}

Cross-Repository Reuse

# Call from another repository
jobs:
  deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@v1
    with:
      environment: production
    secrets: inherit

Caching Strategies

Caching is the most effective way to speed up CI, but misapplied caching is worse than no caching at all.

Dependency Caching

# Node.js caching
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: 'npm'  # Auto-cache node_modules

# Go caching
- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true

# Manual caching (more flexible)
- name: Cache pip
  uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
      ~/.local/lib/python3.*/site-packages
    key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-

Docker Layer Caching

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: buildx-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            buildx-${{ runner.os }}-

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

      # Swap cache directories to prevent unbounded growth
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Caching Best Practices

# 1. Use hashFiles as cache key — auto-invalidates on dependency changes
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# 2. Set restore-keys as fallback — partial hit is better than none
restore-keys: |
  deps-${{ runner.os }}-

# 3. Mind the cache size limit (10GB/repository)
# Oversized caches slow down restoration

# 4. Multi-path caching
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ~/.cache/node
      node_modules
    key: npm-${{ hashFiles('package-lock.json') }}

Custom Actions

Composite Action: Combine Multiple Steps

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build'
inputs:
  node-version:
    description: 'Node.js version'
    default: '22'
  build-args:
    description: 'Build arguments'
    default: ''

runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      shell: bash
      run: npm ci

    - name: Build
      shell: bash
      run: npm run build ${{ inputs.build-args }}

# Usage
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
        with:
          node-version: '20'

JavaScript Action: More Flexible Logic

// action.yml
// name: 'PR Size Labeler'
// runs:
//   using: 'node20'
//   main: 'dist/index.js'

const core = require('@actions/core');
const github = require('@actions/core');

async function run() {
  const { pull_request } = github.context.payload;
  if (!pull_request) {
    core.setFailed('This action only works on pull requests');
    return;
  }

  const additions = pull_request.additions;
  const deletions = pull_request.deletions;
  const total = additions + deletions;

  let label;
  if (total < 50) label = 'size/S';
  else if (total < 200) label = 'size/M';
  else if (total < 500) label = 'size/L';
  else label = 'size/XL';

  core.setOutput('label', label);
  core.setOutput('total_changes', total);
}

run();

Security Best Practices

OIDC Authentication: Say Goodbye to Long-lived Keys

# No need to store long-lived keys like AWS_ACCESS_KEY_ID
# Use OIDC to obtain temporary credentials

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required!
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeploy
          aws-region: ap-southeast-1
          # No access-key-id or secret-access-key needed!

      - name: Deploy to EKS
        run: |
          kubectl apply -f k8s/

Secrets Management

# 1. Use GitHub Secrets for sensitive data
# Settings → Secrets and variables → Actions

# 2. Use Environment Secrets (require approval)
# Ideal for production deployments

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      # Requires approver confirmation
      # protection_rules:
      #   required_reviewers: ['team-lead']
    steps:
      - run: echo "Deploying with ${{ secrets.DEPLOY_KEY }}"

# 3. Avoid leaking Secrets in logs
- name: Safe output
  run: |
    # Wrong: echo "${{ secrets.MY_SECRET }}"  # May appear in logs
    # Correct: use environment variables
    echo "::add-mask::$MY_SECRET"
    # GitHub automatically masks known secret values
  env:
    MY_SECRET: ${{ secrets.MY_SECRET }}

Principle of Least Privilege

# Set minimum permissions for each job
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read       # Read-only code
      packages: write      # Write packages

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write      # OIDC

# Global default minimum permissions (recommended)
permissions:
  contents: read

Monorepo Patterns

Change Detection + Conditional Execution

name: Monorepo CI

on: [push, pull_request]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
      backend: ${{ steps.filter.outputs.backend }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
              - 'shared/**'
            backend:
              - 'backend/**'
              - 'shared/**'
              - 'go.mod'

  frontend-test:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - working-directory: frontend
        run: |
          npm ci
          npm test
          npm run build

  backend-test:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - working-directory: backend
        run: |
          go test ./...
          go build -o bin/app

  e2e:
    needs: [frontend-test, backend-test]
    if: always() && (needs.frontend-test.result == 'success' || needs.backend-test.result == 'success')
    runs-on: ubuntu-latest
    steps:
      - run: echo "Running E2E tests..."

Performance Optimization

Parallelization and Dependency Optimization

# Let independent jobs run in parallel
jobs:
  lint:    # Parallel
    runs-on: ubuntu-latest
    steps: [...]

  typecheck:  # Parallel
    runs-on: ubuntu-latest
    steps: [...]

  test:    # Parallel
    runs-on: ubuntu-latest
    steps: [...]

  build:   # Waits for all above
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps: [...]

Reduce Workflow Trigger Frequency

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.gitignore'
  pull_request:
    types: [opened, synchronize]  # Don't need closed
    paths-ignore:
      - '**.md'

Use Concurrency to Avoid Duplicate Runs

# New push to same PR auto-cancels old runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ...

Optimized Docker Build

# Multi-stage build for smaller images
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
# Copy only necessary files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

# Run as non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Common Pitfalls

1. Branch Protection and workflow_dispatch

# Workflows triggered by workflow_dispatch bypass branch protection by default
# Configure required reviewers in Environments instead

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [staging, production]

2. Insufficient GitHub Token Permissions

# GITHUB_TOKEN default permissions may be insufficient
# Error: failed to push some references
# Fix: declare permissions in the job
permissions:
  contents: write  # Allow pushes
  pull-requests: write  # Allow PR comments

3. Assuming Cache Hit When It Missed

# Wrong: assuming cache always exists
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ hashFiles('requirements.txt') }}
- run: pip install -r requirements.txt  # Slow on cache miss

# Correct: check if cache was hit
- uses: actions/cache@v4
  id: cache-pip
  with:
    path: ~/.cache/pip
    key: pip-${{ hashFiles('requirements.txt') }}
- run: pip install -r requirements.txt
  if: steps.cache-pip.outputs.cache-hit != 'true'  # Only install on miss

Conclusion

Building efficient GitHub Actions pipelines requires mastering these core capabilities:

Capability Key Technique Benefit
Multi-environment testing Matrix builds Verify all targets per commit
Logic reuse Reusable workflows Eliminate 80% duplicate config
Faster execution Caching strategies Reduce CI time by 50-70%
Security OIDC + least privilege Eliminate long-lived key risks
Monorepo Change detection + conditional Build only what changed
Efficiency Concurrency + parallelism Avoid wasted resources

Core principle: security first, speed second, maintainability third. An insecure CI/CD pipeline is more dangerous than no CI/CD at all.

Comments