LabHub

ブログ

GitHub Actions 上級パターン:Reusable Workflows・Composite Actions・モノレポCI/CD最適化戦略

한국어English日本語

GitHub Actions 上級パターン

はじめに

組織のリポジトリが10個を超えると、CI/CDパイプラインの重複が目につきはじめる。ビルド・テスト・デプロイのYAMLがリポジトリごとにコピーして貼り付けられた形で散らばっており、ひとつのセキュリティパッチを当てるだけでも数十のワークフローを一つずつ修正しなければならない。Node.jsのバージョンを18から20に上げるだけの単純な変更が、30のリポジトリにまたがるPRへ膨れ上がる経験をしたことがあるはずだ。

GitHub Actionsはこの問題を解決するために2つの再利用メカニズムを提供する。Reusable Workflowsはワークフロー全体をテンプレート化して呼び出せるようにし、Composite Actionsは複数のステップをひとつのアクションにまとめてモジュール化できるようにする。

ここにモノレポ環境まで重なると、複雑さは一気に上がる。変更されたパッケージだけをビルドし、依存グラフを追跡し、キャッシュをパッケージごとに分離しなければならない。本記事ではこの3つのテーマを実践的なコードとともに深く扱う。

Reusable Workflowsの詳解

workflow_callトリガーの構造

Reusable Workflowは on: workflow_call トリガーを定義するところから始まる。呼び出し側(caller)は uses キーワードでこのワークフローを参照し、inputs、secrets、outputsを通じてデータをやり取りする。

2025年11月のアップデート以降の主な制約は次のとおりだ:

中央集約型のワークフロー管理パターン

大規模な組織では .github リポジトリや別途の platform-workflows リポジトリにReusable Workflowをまとめておき、すべてのチームがそれを参照するようにする。このパターンの核心はバージョンタグの管理だ。

org-platform/
  .github/
    workflows/
      build-node.yml        # Node.jsビルドパイプライン
      build-python.yml      # Pythonビルドパイプライン
      deploy-k8s.yml        # Kubernetesデプロイパイプライン
      security-scan.yml     # セキュリティスキャン共通パイプライン

Reusable Workflowの定義例

以下はNode.jsアプリケーションのビルド・テスト・デプロイをテンプレート化したReusable Workflowだ。

# org-platform/.github/workflows/build-node.yml
name: Reusable Node.js Build

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.jsのバージョン'
        required: false
        type: string
        default: '20'
      working-directory:
        description: '作業ディレクトリ'
        required: false
        type: string
        default: '.'
      run-e2e:
        description: 'E2Eテストを実行するかどうか'
        required: false
        type: boolean
        default: false
      artifact-name:
        description: 'ビルドアーティファクト名'
        required: false
        type: string
        default: 'build-output'
    secrets:
      NPM_TOKEN:
        description: 'npmレジストリのトークン'
        required: false
      SONAR_TOKEN:
        description: 'SonarQube分析のトークン'
        required: false
    outputs:
      build-version:
        description: 'ビルドされたバージョン'
        value: ${{ jobs.build.outputs.version }}
      test-coverage:
        description: 'テストカバレッジ'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Build
        working-directory: ${{ inputs.working-directory }}
        run: npm run build

      - name: Extract version
        id: version
        working-directory: ${{ inputs.working-directory }}
        run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/
          retention-days: 7

  test:
    runs-on: ubuntu-latest
    needs: build
    outputs:
      coverage: ${{ steps.coverage.outputs.percentage }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Run unit tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test -- --coverage

      - name: Extract coverage
        id: coverage
        working-directory: ${{ inputs.working-directory }}
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"

      - name: SonarQube analysis
        if: secrets.SONAR_TOKEN != ''
        uses: SonarSource/sonarqube-scan-action@v3
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  e2e:
    if: inputs.run-e2e
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4

      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/

      - name: Run E2E tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test:e2e

Caller Workflowの例

上のReusable Workflowを呼び出すcallerワークフローは次のとおりだ。

# my-service/.github/workflows/ci.yml
name: CI Pipeline

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

jobs:
  build-and-test:
    uses: org-platform/.github/workflows/build-node.yml@v2.3.0
    with:
      node-version: '20'
      working-directory: '.'
      run-e2e: ${{ github.ref == 'refs/heads/main' }}
      artifact-name: 'my-service-build'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  deploy-staging:
    needs: build-and-test
    if: github.ref == 'refs/heads/develop'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: staging
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

  deploy-production:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: production
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

secrets: inherit を使えばcallerのすべてのシークレットが自動的に渡される。セキュリティが重要な環境では、必要なシークレットだけを明示的に渡すのがよい。

Composite Actionsの構築

action.ymlの構造と中心となる概念

Composite Actionは複数のステップをひとつの再利用可能なアクションにまとめる。Reusable Workflowと違ってジョブ単位ではなくステップ単位で動作し、ひとつのジョブの中に複数のComposite Actionを含められる。

JavaScript/Docker/Compositeアクションの比較

項目JavaScript ActionDocker ActionComposite Action
実行環境Node.jsランタイムDockerコンテナランナーで直接実行
起動時間速い (数秒)遅い (イメージのプル)速い (数秒)
プラットフォーム互換Linux/macOS/WindowsLinux専用ランナーのOSによって異なる
複雑なロジック最適適するシェルスクリプト程度
既存ツールの活用npmパッケージを使うどんなツールでもインストールできる他のアクションを組み合わせられる
保守の難易度中 (ビルドが必要)低 (Dockerfile)低 (YAMLのみ)
シークレットへのアクセス直接アクセスできる直接アクセスできる環境変数でのみ渡す

Composite Actionの定義例

以下はDockerイメージのビルドとプッシュをモジュール化したComposite Actionだ。

# .github/actions/docker-build-push/action.yml
name: 'Docker Build and Push'
description: 'Dockerイメージをビルドしてレジストリにプッシュします'

inputs:
  registry:
    description: 'コンテナレジストリのURL'
    required: true
  image-name:
    description: 'イメージ名'
    required: true
  dockerfile:
    description: 'Dockerfileのパス'
    required: false
    default: './Dockerfile'
  context:
    description: 'ビルドコンテキストのパス'
    required: false
    default: '.'
  build-args:
    description: 'ビルド引数 (改行区切り)'
    required: false
    default: ''
  push:
    description: 'イメージをプッシュするかどうか'
    required: false
    default: 'true'

outputs:
  image-digest:
    description: 'ビルドされたイメージのダイジェスト'
    value: ${{ steps.build.outputs.digest }}
  image-tag:
    description: 'イメージのタグ'
    value: ${{ steps.meta.outputs.tags }}

runs:
  using: 'composite'
  steps:
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ inputs.registry }}/${{ inputs.image-name }}
        tags: |
          type=sha,prefix=
          type=ref,event=branch
          type=ref,event=tag
          type=semver,pattern=v{{version}}
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Build and push
      id: build
      uses: docker/build-push-action@v6
      with:
        context: ${{ inputs.context }}
        file: ${{ inputs.dockerfile }}
        push: ${{ inputs.push }}
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        build-args: ${{ inputs.build-args }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        provenance: true
        sbom: true

    - name: Print image info
      shell: bash
      run: |
        echo "Image digest: ${{ steps.build.outputs.digest }}"
        echo "Image tags: ${{ steps.meta.outputs.tags }}"

Composite Actionの使用例

# my-service/.github/workflows/build-image.yml
name: Build Docker Image

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'Dockerfile'

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4

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

      - name: Build and Push
        id: docker
        uses: ./.github/actions/docker-build-push
        with:
          registry: ghcr.io
          image-name: ${{ github.repository }}
          build-args: |
            APP_VERSION=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

      - name: Deploy notification
        run: |
          echo "Deployed image with digest: ${{ steps.docker.outputs.image-digest }}"

Reusable Workflows と Composite Actions の比較

2つのメカニズムは用途が異なる。選択の基準をはっきりさせて初めて、パイプラインのアーキテクチャがすっきりする。

項目Reusable WorkflowsComposite Actions
動作する単位ジョブ(Job)単位ステップ(Step)単位
シークレットへのアクセスsecrets キーワードで直接渡す環境変数でしか渡せない
ネスト呼び出し最大10段階最大10段階
条件付き実行ジョブ単位の if が可能ステップ単位の if が可能
ランナーの指定呼び出される側で指定できる呼び出し側のランナーで実行
呼び出し方jobs.xxx.uses:steps.xxx.uses:
outputsの受け渡しワークフローのoutputsに対応ステップのoutputsに対応
環境(environment)別のenvironmentを指定できる呼び出し側のenvironmentを共有
最大呼び出し数ワークフローあたり50個制限なし
向いているシナリオパイプライン全体のテンプレート共通ステップのモジュール

選択のガイド:

モノレポCI/CDの最適化

モノレポで最も重要な原則は変更されたパッケージだけをビルドすることだ。すべての変更に対してリポジトリ全体をビルドすると、CI時間は指数関数的に増える。

dorny/paths-filterを活用した変更検知

dorny/paths-filter は、PRやpushで変更されたファイルパスをもとにbooleanのフィルターを生成する。このフィルターを後続ジョブの条件として使えば、変更されたパッケージだけを選択的にビルドできる。

動的matrix戦略とfromJSONパターン

静的なmatrixは常にすべての組み合わせを実行する。モノレポでは、変更されたパッケージの一覧を動的に生成してmatrixに注入するパターンが欠かせない。fromJSON 関数を使えばJSON文字列をmatrixの値に変換できる。

モノレポのpath-filter + dynamic matrix YAML

以下は、モノレポで変更検知と動的matrixを組み合わせた実践的なワークフローだ。

# .github/workflows/monorepo-ci.yml
name: Monorepo CI

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

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.changes }}
      api-changed: ${{ steps.filter.outputs.api }}
      web-changed: ${{ steps.filter.outputs.web }}
      shared-changed: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v4

      - name: Detect changed packages
        uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
              - 'packages/shared/**'
            web:
              - 'packages/web/**'
              - 'packages/shared/**'
            shared:
              - 'packages/shared/**'
            docs:
              - 'packages/docs/**'

  build-matrix:
    needs: detect-changes
    if: needs.detect-changes.outputs.packages != '[]'
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - name: Build dynamic matrix
        id: set-matrix
        run: |
          CHANGES='${{ needs.detect-changes.outputs.packages }}'
          MATRIX=$(echo "$CHANGES" | jq -c '{package: .}')
          echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"

  build:
    needs: build-matrix
    if: needs.build-matrix.outputs.matrix != ''
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.build-matrix.outputs.matrix) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build package
        run: npm run build --workspace=packages/${{ matrix.package }}

      - name: Test package
        run: npm run test --workspace=packages/${{ matrix.package }}

  integration-test:
    needs: [detect-changes, build]
    if: >-
      needs.detect-changes.outputs.api-changed == 'true' ||
      needs.detect-changes.outputs.web-changed == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install all dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

  deploy:
    needs: [detect-changes, build, integration-test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - package: api
            changed: ${{ needs.detect-changes.outputs.api-changed }}
          - package: web
            changed: ${{ needs.detect-changes.outputs.web-changed }}
      fail-fast: false
    steps:
      - name: Skip if not changed
        if: matrix.changed != 'true'
        run: echo "Skipping deploy for ${{ matrix.package }} (no changes)"

      - uses: actions/checkout@v4
        if: matrix.changed == 'true'

      - name: Deploy
        if: matrix.changed == 'true'
        run: |
          echo "Deploying ${{ matrix.package }}..."
          # 実際のデプロイ処理

キャッシング最適化のYAML

モノレポでのキャッシュ戦略は、パッケージごとの分離が肝になる。グローバルキャッシュとパッケージ別キャッシュを階層的に管理する。

# .github/workflows/cache-optimized.yml
name: Cache Optimized Build

on:
  push:
    branches: [main]
  pull_request:

env:
  TURBO_CACHE_DIR: .turbo

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      # 第1段階: npm依存関係のキャッシュ (package-lock.jsonベース)
      - name: Setup Node.js with dependency cache
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # 第2段階: Turborepoのリモートキャッシュではなくローカルキャッシュを使う
      - name: Cache Turborepo
        uses: actions/cache@v4
        with:
          path: .turbo
          key: turbo-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            turbo-${{ runner.os }}-

      # 第3段階: Next.jsのビルドキャッシュ (ページ単位の増分ビルド)
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: packages/web/.next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('packages/web/**/*.ts', 'packages/web/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-

      # 第4段階: ESLintのキャッシュ
      - name: Cache ESLint
        uses: actions/cache@v4
        with:
          path: .eslintcache
          key: eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-${{ github.sha }}
          restore-keys: |
            eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-
            eslint-${{ runner.os }}-

      # 第5段階: Jestのキャッシュ
      - name: Cache Jest
        uses: actions/cache@v4
        with:
          path: /tmp/jest_rs
          key: jest-${{ runner.os }}-${{ hashFiles('**/jest.config.*') }}
          restore-keys: |
            jest-${{ runner.os }}-

      - name: Install dependencies
        run: npm ci

      - name: Build with Turborepo
        run: npx turbo run build --cache-dir=.turbo

      - name: Test with Turborepo
        run: npx turbo run test --cache-dir=.turbo

      - name: Lint with cache
        run: npx turbo run lint --cache-dir=.turbo

上のキャッシュ戦略を適用すると、一般的なモノレポでは次のような効果が得られる:

キャッシュ対象キャッシュミス時キャッシュヒット時削減率
npm依存関係45〜90秒5〜10秒80〜90%
Turborepoビルド120〜300秒3〜8秒95%+
Next.js増分ビルド60〜180秒10〜30秒70〜85%
ESLint分析30〜60秒5〜15秒60〜75%
Jestテストキャッシュ不可transformキャッシュ20〜40%

本番運用ガイド

ワークフローのバージョン管理戦略

Reusable WorkflowとComposite Actionの参照の仕方によって、安定性とセキュリティは大きく変わる。

参照方式利点欠点
ブランチuses: org/repo/.github/workflows/ci.yml@main常に最新バージョン予告なく壊れることがある
タグuses: org/repo/.github/workflows/ci.yml@v2.3.0安定しており、SemVerの意味が伝わるタグを上書きされうる
コミットSHAuses: org/repo/.github/workflows/ci.yml@a1b2c3d4最も安全で、変わらない可読性が低い

推奨戦略: 開発中はブランチ参照、本番ではSHA参照を使う。DependabotやRenovateを設定しておけば、SHAが更新されたときに自動でPRを作ってくれる。

組織全体のガバナンスと標準化

組織レベルで次を強制する:

セキュリティ: OIDCと最小権限の原則

クラウドへのデプロイでは、長期の資格情報(アクセスキーなど)をシークレットに保存する代わりに、OIDC (OpenID Connect)を使って一時的なトークンを発行してもらう。

# OIDCを使ったAWSデプロイの例
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: ap-northeast-2
          role-session-name: github-actions-${{ github.run_id }}

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-api \
            --force-new-deployment

この方式はシークレット漏洩のリスクを根本から取り除き、IAM Roleのtrust policyで特定のリポジトリとブランチだけを許可できる。

障害事例とトラブルシューティング

事例1: シークレットの受け渡し失敗でデプロイが停止

症状: Reusable WorkflowからのAWSデプロイが失敗する。エラーメッセージは「credentials not found」。

原因: callerワークフローで secrets: inherit ではなく明示的なシークレットの受け渡しを使っていたが、新しく追加された AWS_ROLE_ARN シークレットを渡していなかった。

教訓: secrets: inherit は便利だが、どのシークレットが渡っているのかを追いにくい。本番環境では明示的な受け渡しを勧めるが、新しいシークレットを追加したときにcallerの更新を忘れないよう、CIに検証ステップを追加しておくこと。

解決パターン:

# シークレットの受け渡し検証を含むcallerワークフロー
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Validate required secrets
        run: |
          MISSING=""
          if [ -z "$AWS_ROLE" ]; then MISSING="$MISSING AWS_ROLE_ARN"; fi
          if [ -z "$NPM_TK" ]; then MISSING="$MISSING NPM_TOKEN"; fi
          if [ -n "$MISSING" ]; then
            echo "::error::Missing required secrets:$MISSING"
            exit 1
          fi
        env:
          AWS_ROLE: ${{ secrets.AWS_ROLE_ARN }}
          NPM_TK: ${{ secrets.NPM_TOKEN }}

  deploy:
    needs: validate
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

事例2: キャッシュミスでCI時間が急増

症状: 月曜の朝になるたびに、モノレポのCI時間が普段の5分から25分に増える。

原因: GitHub Actionsのキャッシュは7日間使われないと自動的に削除される。週末のあいだにキャッシュが失効し、月曜の最初のビルドですべてのキャッシュがミス扱いになっていた。

解決: スケジュールワークフローで週末もキャッシュを更新し、restore-keys パターンを階層的に設定する。

デバッグのチェックリスト

問題が起きたら次の順番で点検する:

  1. ワークフロー権限の確認: permissions ブロックが必要な権限をすべて含んでいるか
  2. シークレットのスコープ確認: Organizationのシークレットか、Repositoryのシークレットか、Environmentのシークレットか
  3. キャッシュキーのパターン確認: hashFiles のパスが実在するファイルを指しているか
  4. Reusable Workflowへのアクセス権限: 呼び出される側のリポジトリのSettingsでActionsへのアクセスを許可したか
  5. matrixの値の検証: fromJSON に渡される文字列が有効なJSONかどうかを確認
  6. 環境変数のスコープ: Reusable Workflowにはcallerの env が渡されない点を忘れない

運用上の注意事項

呼び出し制限と同時実行の管理

GitHub Actionsには次のような制限がある:

同時実行の問題を防ぐには concurrency グループを設定する:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

同じブランチへのデプロイが同時に実行されるのを防ぎ、cancel-in-progress: false によって実行中のデプロイをキャンセルしない。

環境変数のスコープに注意

Reusable Workflowで最もよくある誤りは、環境変数のスコープを取り違えることだ:

必要な値は必ず inputs で明示的に渡さなければならない。

大規模モノレポの性能のヒント

- uses: actions/checkout@v4
  with:
    sparse-checkout: |
      packages/api
      packages/shared
      package.json
      package-lock.json
    sparse-checkout-cone-mode: false

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます