⏩ Optimizing GitHub Actions Workflows for Speed and Efficiency

Thumbnail Image

Slow CI/CD pipelines directly impact developer productivity and deployment frequency. When pull request checks take 15+ minutes to complete, developers context-switch to other tasks, breaking their flow and reducing productivity. Additionally, GitHub Actions charges by the minute, so inefficient workflows literally cost you money.

I’ll go through some optimization techniques I’ve found to be effective:

Caching Dependencies

The most immediate win for most workflows is proper dependency caching:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'
          
      - name: Cache dependencies
        uses: actions/cache@v3
        with:
          path: |
            ~/.npm
            node_modules
          key: $-node-$
          restore-keys: |
            $-node-
            
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit

The above example includes several optimizations:

  • Caching both ~/.npm and node_modules
  • Using lockfile hashing for cache keys
  • Fallback cache keys for partial hits
  • --prefer-offline flag to prioritize cached packages

Optimize Docker Usage

For workflows using Docker, image layering and caching are critical:

jobs:
  docker-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
        
      - name: Cache Docker layers
        uses: actions/cache@v3
        with:
          path: /tmp/.buildx-cache
          key: $-buildx-$
          restore-keys: |
            $-buildx-
            
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: false
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

After your build, add this step to prevent cache size explosion:

      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Strategic Job Splitting and Parallelization

Instead of sequential steps, split work into parallel jobs when possible:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        # ...lint setup and execution
        
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        # ...test setup and execution
        
  build:
    runs-on: ubuntu-latest
    needs: [lint, test]
    # This job only runs after lint and test complete successfully

Matrix Builds for Testing Efficiently

For multi-environment testing, use matrix builds:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14, 16, 18]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js $
        uses: actions/setup-node@v3
        with:
          node-version: $
      - run: npm ci
      - run: npm test

Selective Testing with Path Filtering

Avoid running unnecessary jobs by filtering based on changed paths:

jobs:
  frontend-tests:
    if: |
      github.event_name == 'pull_request' &&
      (github.event.pull_request.base.ref == 'main' ||
       contains(github.event.pull_request.labels.*.name, 'run-all-tests'))
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
              - 'shared/**'
              
      - name: Frontend Tests
        if: steps.filter.outputs.frontend == 'true'
        run: npm test

Self-hosted Runners for Specialized Workloads

For specific needs like GPU access or large compute requirements, self-hosted runners can dramatically improve performance:

jobs:
  gpu-training:
    runs-on: self-hosted-gpu
    steps:
      - uses: actions/checkout@v3
      # GPU-intensive tasks here

Monitoring Your Workflow Performance

GitHub provides insights into workflow run times. Use the GitHub API to track this data over time:

async function getWorkflowRunTimes(owner, repo, workflow_id) {
  const { Octokit } = require("@octokit/rest");
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
  
  const runs = await octokit.actions.listWorkflowRuns({
    owner,
    repo,
    workflow_id,
  });
  
  return runs.data.workflow_runs.map(run => ({
    id: run.id,
    duration: new Date(run.updated_at) - new Date(run.created_at),
    conclusion: run.conclusion
  }));
}

Conclusion

Optimizing GitHub Actions workflows is an ongoing process. Start with the high-impact items like caching and job parallelization, then iteratively improve based on metrics. The reward is not just faster builds but happier developers and lower costs.