iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

3 Optimization Techniques to Reduce GitHub Actions CI/CD Time to 5 Minutes

に公開

Introduction

"CI/CD builds are too slow..."
"Tests take 30 minutes, so deploying is a pain..."
"Isn't it a waste of time to do a full build every time?"

When CI/CD pipeline execution time is long, development speed drops significantly. However, with proper optimization, it is possible to reduce build time from 30 minutes to 5 minutes (-83%).

This article explains three optimization techniques based on a hypothetical scenario. The numbers are estimates based on general benchmarks.

Assumed Scenario: Situation Before Optimization

First, let's look at the assumed CI/CD execution time before optimization.

Assumed Project

  • Scale: Next.js 14 + TypeScript, iOS App (SwiftUI), 200,000 lines of code
  • Team: Approximately 7 developers
  • Deployment: Web (Vercel) + iOS (TestFlight)

Common CI/CD Problems

Execution time:
- Web CI: 15 min/run
- iOS CI: 30 min/run
- Daily CI runs: Average 20 times
- Total waiting time: Approx. 10 hours/day

Cost:
- GitHub Actions minutes: 20,000 min/month
- Overage charges: $400/month
- Developer waiting time cost: 1.5 million JPY/month

Process issues:
- No caching → Full build every time
- No parallelism → Tests executed serially
- Unnecessary steps → Deployment runs for every PR

A particularly problematic situation is that developers are waiting an average of 30 minutes × 20 times = 10 hours/day for CI to complete.

Expected Improvement Effects: -83% Reduction with 3 Optimizations

By properly implementing optimizations, you can expect the following improvements.

Improvement in Build Times

Metrics Before Optimization After Optimization Improvement Rate
Web CI Execution Time 15 min 3 min -80%
iOS CI Execution Time 30 min 5 min -83%
Total Daily Waiting Time 10 hours 2.5 hours -75%
GitHub Actions Minutes 20,000 min/month 5,000 min/month -75%
Monthly Cost $400 $0 -100%

Optimization Method 1: -60% Reduction with Dependency Caching

The most effective approach is dependency caching.

Before: No Caching (15 minutes)

# .github/workflows/test.yml (Before)
name: Test

on:
  pull_request:
  push:
    branches: [main]

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

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      # ❌ Full install every time: 8 minutes
      - name: Install dependencies
        run: npm ci

      # Run tests: 7 minutes
      - name: Run tests
        run: npm test

Execution Time Breakdown:

  • npm ci: 8 min (Download dependencies + Installation)
  • Test execution: 7 min
  • Total: 15 min

After: Caching Enabled (6 minutes)

# .github/workflows/test.yml (After)
name: Test

on:
  pull_request:
  push:
    branches: [main]

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

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          # ✅ Enable caching
          cache: 'npm'

      # ✅ On cache hit: 30 seconds
      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Execution Time Breakdown (on cache hit):

  • npm ci: 30 sec (Restored from cache)
  • Test execution: 7 min
  • Total: 7.5 min (-50%)

iOS: Caching CocoaPods

# .github/workflows/ios.yml
name: iOS CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      # ✅ CocoaPods cache
      - name: Cache CocoaPods
        uses: actions/cache@v4
        with:
          path: Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-pods-

      # On cache hit: 1 min (Full install: 12 min)
      - name: Install CocoaPods dependencies
        run: |
          if [ ! -d "Pods" ]; then
            pod install
          fi

      # ✅ Build cache (DerivedData)
      - name: Cache DerivedData
        uses: actions/cache@v4
        with:
          path: ~/Library/Developer/Xcode/DerivedData
          key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift') }}

      - name: Build and test
        run: |
          xcodebuild test \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15'

Caching Effects:

  • CocoaPods: 12 min → 1 min (-92%)
  • Xcode DerivedData: 15 min → 3 min (-80%)

Optimization Method 2: -50% Reduction with Parallel Execution

Next, you can expect significant results from parallel execution of tests.

Before: Serial Execution (7 minutes)

# Before: Everything executed serially
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4

      # 1. Lint: 2 min
      - run: npm run lint

      # 2. Unit tests: 3 min
      - run: npm run test:unit

      # 3. E2E tests: 2 min
      - run: npm run test:e2e

Total execution time: 7 minutes

After: Parallel Execution (3 minutes)

# After: Parallelizing jobs
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          cache: 'npm'
      - run: npm ci
      - run: npm run lint  # 2 min

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit  # 3 min

  e2e-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          cache: 'npm'
      - run: npm ci
      - run: npm run test:e2e  # 2 min

By using parallel execution, it completes in 3 minutes, the duration of the longest job (-57%)

Further Parallelization with Matrix Strategy

# Run E2E tests in parallel across multiple browsers
jobs:
  e2e-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chromium, firefox, webkit]
        shard: [1, 2, 3]  # Split into 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci

      # Run split tests with sharding
      - run: npx playwright test --shard=${{ matrix.shard }}/3
        env:
          BROWSER: ${{ matrix.browser }}

Effects:

  • E2E tests: 6 min → 2 min (split into 3 shards)
  • Multi-browser tests: Sequential execution → Parallel execution

Optimization Method 3: Reducing Unnecessary Steps with Conditional Execution

Finally, limiting execution based on changed files is also effective.

Before: Always executing all steps

# Before: Running deployment even on PRs (wasteful)
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
      - run: vercel --prod  # Runs on PRs as well (unnecessary)

After: Conditional execution

# After: Deploying only on the main branch
jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # ✅ main only
    steps:
      - run: npm run build
      - run: vercel --prod

  # Preview deployment only on PRs
  preview:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - run: npm run build
      - run: vercel  # Preview environment

Execution control with file change detection

# Run Web tests only when Web files are changed
jobs:
  web-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Fetch history

      # ✅ Detect changed files
      - name: Check changed files
        id: changed-files
        uses: tj-actions/changed-files@v41
        with:
          files: |
            src/**
            package.json
            package-lock.json

      # Execute only if web files have changed
      - name: Run web tests
        if: steps.changed-files.outputs.any_changed == 'true'
        run: npm run test:web

iOS: Speeding up code signing with Fastlane Match

# iOS: Code signing cache
jobs:
  ios-build:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      # ✅ Manage certificates with Fastlane Match
      - name: Setup certificates
        run: fastlane match development --readonly
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}

      # ✅ Build only (Archive only on main)
      - name: Build
        if: github.event_name == 'pull_request'
        run: xcodebuild build -workspace MyApp.xcworkspace -scheme MyApp

      # Archive & Upload only on the main branch
      - name: Archive and Upload
        if: github.ref == 'refs/heads/main'
        run: fastlane beta

Effects:

  • iOS CI on PRs: 30 min → 5 min (Build only)
  • Deployment on main: 30 min → 12 min (Leveraging cache)

Performance Comparison After Optimization

Step Before Optimization After Optimization Improvement Rate
Dependency Installation 8 min 0.5 min -94%
Test Execution (Parallelization) 7 min 3 min -57%
Conditional Execution Deploy every time Skip for PRs -100%
Total (Web) 15 min 3 min -80%
Total (iOS) 30 min 5 min -83%

Cost Reduction Effects

Metrics Before Optimization After Optimization Savings
GitHub Actions Minutes 20,000 min/month 5,000 min/month -
Overage Charges $400/month $0/month $4,800/year
Developer Waiting Time 10 hours/day 2.5 hours/day 1.25 million JPY/month
Annual Savings - - Approx. 15 million JPY

Summary

Optimization Method Implementation Difficulty Improvement Effect Recommendation Level
Dependency Caching ★☆☆ -60% ★★★
Parallel Execution ★★☆ -50% ★★★
Conditional Execution ★☆☆ -30% ★★☆

CI/CD optimization, when implemented correctly, will dramatically improve development speed. Let's start by enabling caching.

GitHubで編集を提案

Discussion