Part 4: Advanced Workflows and Optimization

Introduction

After building basic CI/CD pipelines, I realized I was duplicating a lot of workflow code across my microservices. Each service had nearly identical workflows for linting, testing, and building. This is where reusable workflows and composite actions saved me hundreds of lines of YAML.

In this part, I'll show you advanced patterns that will make your workflows DRY (Don't Repeat Yourself), faster, and more maintainable.

Reusable Workflows

Reusable workflows let you create a workflow template that other workflows can call.

Creating a Reusable Workflow

.github/workflows/reusable-node-ci.yml:

name: Reusable Node.js CI

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version to use'
        required: false
        type: string
        default: '18'
      service-path:
        description: 'Path to the service'
        required: true
        type: string
      run-integration-tests:
        description: 'Run integration tests'
        required: false
        type: boolean
        default: true
    secrets:
      codecov-token:
        description: 'Codecov token'
        required: false
      docker-username:
        required: false
      docker-password:
        required: false

env:
  NODE_VERSION: ${{ inputs.node-version }}
  SERVICE_PATH: ${{ inputs.service-path }}

jobs:
  lint:
    name: Lint and Type Check
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: '${{ env.SERVICE_PATH }}/package-lock.json'
      
      - name: Install dependencies
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm ci
      
      - name: Run linter
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm run lint
      
      - name: Type check
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm run type-check
  
  test:
    name: Test
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
      
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: '${{ env.SERVICE_PATH }}/package-lock.json'
      
      - name: Install dependencies
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm ci
      
      - name: Generate Prisma Client
        working-directory: ${{ env.SERVICE_PATH }}
        run: npx prisma generate
      
      - name: Run migrations
        if: inputs.run-integration-tests
        working-directory: ${{ env.SERVICE_PATH }}
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
      
      - name: Run tests
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
          REDIS_URL: redis://localhost:6379
      
      - name: Upload coverage
        if: ${{ secrets.codecov-token != '' }}
        uses: codecov/codecov-action@v3
        with:
          files: ./${{ env.SERVICE_PATH }}/coverage/lcov.info
          token: ${{ secrets.codecov-token }}
  
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: '${{ env.SERVICE_PATH }}/package-lock.json'
      
      - name: Install dependencies
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm ci
      
      - name: Build
        working-directory: ${{ env.SERVICE_PATH }}
        run: npm run build

Using the Reusable Workflow

.github/workflows/auth-ci.yml:

.github/workflows/payment-ci.yml:

Composite Actions

Composite actions let you bundle multiple steps into a single reusable action.

Creating a Composite Action

.github/actions/setup-node-service/action.yml:

Using the Composite Action

Advanced Composite Action with Outputs

.github/actions/extract-service-info/action.yml:

Using it:

Concurrency Control

Prevent multiple workflow runs from interfering with each other:

More specific concurrency:

Per-service concurrency:

Artifacts and Caching

Uploading and Downloading Artifacts

Advanced Caching Strategy

Job Outputs

Pass data between jobs:

Dynamic Matrix from JSON

.github/test-matrix.json:

Conditional Workflows

Run Different Jobs Based on Conditions

Performance Optimization

Parallel Jobs

Split Tests Across Multiple Runners

Reduce Checkout Time

Self-Hosted Runners

For faster builds or special requirements:

Setting up a self-hosted runner:

  1. Go to repository Settings → Actions → Runners

  2. Click "New self-hosted runner"

  3. Follow instructions to download and configure

  4. Label your runner (e.g., high-memory, gpu, production)

Workflow Visualization

Create status badges for your README:

Advanced Debugging

Enable Debug Logging

Set repository secrets:

  • ACTIONS_STEP_DEBUG: true

  • ACTIONS_RUNNER_DEBUG: true

Add Debug Step

SSH Debugging

Key Takeaways

  1. Reusable workflows eliminate duplication across similar workflows

  2. Composite actions bundle common steps into reusable units

  3. Concurrency control prevents race conditions and saves compute time

  4. Smart caching dramatically reduces workflow run time

  5. Job outputs enable complex, multi-stage workflows

  6. Dynamic matrices allow flexible testing strategies

  7. Conditional jobs save resources by only running when needed

  8. Parallel execution speeds up total workflow time

  9. Self-hosted runners provide more control and power

  10. Proper debugging tools help diagnose workflow issues

In the next part, we'll cover production deployment strategies, including blue-green deployments, rollback mechanisms, and monitoring.

Last updated