Loading
BLOG 開発者ブログ

2025年12月26日

GitHub Actions を並列で動かして CI を最適化する

GitHub Actions

この記事はアドベントカレンダー アイソルート Advent Calendar 2025 の 19 日目の記事です。

はじめに

こんにちは、普段は TypeScript を中心とした Web アプリケーション開発を行っている murakami.s です。
今回は、GitHub Actions を使った CI を高速化する方法について紹介します。

目次

ベースとなるプロジェクト構成

まず、今回紹介する手法を適用するベースとなるプロジェクト構成について説明します。
今回の例では、サンプルとして GitHub Copilot に以下のような構成のサンタクロース向け在庫管理システムをモノレポ構成で構築してもらっています。

santas-workshop/
├─ README.md
├─ package.json
├─ pnpm-lock.yaml
├─ pnpm-workspace.yaml
├─ biome.jsonc
├─ rust-toolchain.toml
├─ .github/                     # GitHub Actions ワークフローなど(詳細は後述)
│  ├─ actions/
│  ├─ scripts/
│  └─ workflows/
└─ apps/
   ├─ node-service/             # Node.js API サービス
   ├─ web/                      # Next.js Web アプリケーション
   └─ rust-service/             # Rust API サービス

GitHub Actions のワークフローは以下のようになっています。
使用している言語単位でだけ job を分割し、ステップは逐次実行しています。

name: "CI"

on:
  pull_request:
    branches:
      - master
      - develop
  push:
    branches:
      - master
      - develop

jobs:
  node-ci:
    name: "CI for Node.js apps"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      # ========== Node SETUP ==========
      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # ========== License Check ==========
      - name: Generate licenses.json
        run: pnpm run license:gen

      - name: Check licenses
        run: pnpm run license:check

      # ========== Lint / Typecheck / Test / Build ==========
      - name: Lint
        run: pnpm run lint

      - name: Typecheck
        run: pnpm run typecheck

      - name: Test
        run: pnpm run test

      - name: Install Playwright browsers
        run: pnpm --filter web exec playwright install --with-deps chromium

      - name: E2E Test
        run: pnpm run test:e2e

      - name: Build
        run: pnpm run build

  rust-ci:
    name: "CI for Rust apps"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      # ========== Setup Rust ==========
      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable
        with:
          components: "rustfmt"

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
        with:
          workspaces: "./apps/rust-service -> target"

      - name: Install Rust dependencies
        working-directory: apps/rust-service
        run: cargo fetch

      # ========== License Check ==========
      - name: Install cargo-deny
        run: cargo install cargo-deny

      - name: Check Rust licenses
        working-directory: apps/rust-service
        run: cargo deny check licenses

      # ========== Lint / Typecheck / Test / Build ==========
      - name: Rust fmt
        working-directory: apps/rust-service
        run: cargo fmt --all -- --check

      - name: Rust lint
        working-directory: apps/rust-service
        run: cargo clippy --all-targets --all-features -- -D warnings

      - name: Rust build
        working-directory: apps/rust-service
        run: cargo build --all-features --workspace --release

      - name: Rust test
        working-directory: apps/rust-service
        run: cargo test --all-features --workspace

上記のようなワークフローでは、Node.js と Rust の CI がそれぞれ 1 つの job にまとまっており、各ステップが逐次実行されるため、CI の実行時間が長くなってしまいます。
そこで、今から説明する以下の手法を用いて CI の高速化を図ります。

Step1. job を並列化

まず、第一に各言語で実行している job を Lint、Typecheck、Test、Build などの細かい job に分割し、並列で実行できるようにします。

name: "CI"

on:
  pull_request:
    branches:
      - master
      - develop
  push:
    branches:
      - master
      - develop

jobs:
  node-license-check:
    name: "[node] License Check"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Generate licenses.json
        run: pnpm run license:gen

      - name: Check licenses
        run: pnpm run license:check

  node-lint:
    name: "[node] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm run lint

  node-typecheck-and-build:
    name: "[node] Typechecking"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Typecheck
        run: pnpm run typecheck

      - name: Build
        run: pnpm run build

  node-test:
    name: "[node] Testing"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Test
        run: pnpm run test

  node-test-e2e:
    name: "[node] E2E Testing"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Install Playwright browsers
        run: pnpm --filter web exec playwright install --with-deps chromium

      - name: E2E Test
        run: pnpm run test:e2e

  rust-license-check:
    name: "[rust] License Check"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
        with:
          workspaces: "./apps/rust-service -> target"

      - name: Install Rust dependencies
        working-directory: apps/rust-service
        run: cargo fetch

      - name: Install cargo-deny
        run: cargo install cargo-deny

      - name: Check Rust licenses
        working-directory: apps/rust-service
        run: cargo deny check licenses

  rust-lint:
    name: "[rust] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable
        with:
          components: "rustfmt clippy"

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
        with:
          workspaces: "./apps/rust-service -> target"

      - name: Install Rust dependencies
        working-directory: apps/rust-service
        run: cargo fetch

      - name: Rust fmt
        working-directory: apps/rust-service
        run: cargo fmt --all -- --check

      - name: Rust lint
        working-directory: apps/rust-service
        run: cargo clippy --all-targets --all-features -- -D warnings

  rust-build:
    name: "[rust] Build"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
        with:
          workspaces: "./apps/rust-service -> target"

      - name: Install Rust dependencies
        working-directory: apps/rust-service
        run: cargo fetch

      - name: Rust build
        working-directory: apps/rust-service
        run: cargo build --all-features --workspace --release

  rust-test:
    name: "[rust] Test"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
        with:
          workspaces: "./apps/rust-service -> target"

      - name: Rust test
        working-directory: apps/rust-service
        run: cargo test --all-features --workspace

これで各 job が細かく分割され、並列で実行されるようになりました。
これにより、CI 全体の実行時間が大幅に短縮されます。

Before

並列化前の GitHub Actions の実行時の画像。Node.jsのワークフローとRustのワークフローが実行されている。

After

今回は小規模なコードベースのため、大きな効果は見られませんが、より大規模なコードベースや複数のサービスを含むモノレポ構成の場合、CI の実行時間を劇的に短縮できます。

しかし、見ての通り job 数の増加による、ワークフローの定義が冗長になってしまいました。
セットアップ処理などの重複も多く、メンテナンス性が低下してしまいます。
そこで、次に紹介する手法でワークフローの共通化を図ります。

Step2. Composite Action で共通化

Composite Action とは

公式ドキュメント

複数の step を別ファイルに切り出し、他のワークフローから呼び出して再利用できる機能です。
これにより、ワークフローの共通化やメンテナンス性の向上が図れます。
呼び出し先では uses を使用して Composite Action を指定します。

Composite Action を使用して共通化する

以下の構成で Composite Action を作成し、Node.js と Rust のセットアップ処理を共通化します。

.github/
├─ actions/
│  ├─ setup-node/
│  │  └─ action.yml
│  └─ setup-rust/
│     └─ action.yml
└─ workflows/
   └─ ci.yml

.github/actions/setup-node/action.yml (Node.js のセットアップ用)

name: "Setup Node.js Environment"
description: "Setup Node.js with caching and dependency installation"
inputs:
  node-version:
    description: "Node.js version to install"
    required: false
    default: "24"
  install-deps:
    description: "Whether to install dependencies"
    required: false
    default: "true"

runs:
  using: "composite"
  steps:
    - name: Setup pnpm
      uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

    - name: Setup Node.js
      uses: actions/setup-node@v6
      with:
        node-version: ${{ inputs.node-version }}
        cache: "pnpm"
        cache-dependency-path: pnpm-lock.yaml

    - name: Install dependencies
      if: inputs.install-deps == 'true'
      run: pnpm install --frozen-lockfile
      shell: bash

.github/actions/setup-rust/action.yml (Rust のセットアップ用)

name: "Setup Rust Environment"
description: "Setup Rust toolchain with caching and components"
inputs:
  components:
    description: "Additional components to install"
    required: false
    default: ""

runs:
  using: "composite"
  steps:
    - uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable
    - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
      with:
        workspaces: "./apps/rust-service -> target"

    - name: Install Rust dependencies
      working-directory: apps/rust-service
      run: cargo fetch
      shell: bash

    - name: Install Rust components
      if: inputs.components != ''
      run: rustup component add ${{ inputs.components }}
      shell: bash

.github/workflows/ci.yml (CI ワークフロー) ※一部のみ抜粋

jobs:
  # 省略...
  node-lint:
    name: "[node] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Lint
        run: pnpm run lint

  rust-lint:
    name: "[rust] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-rust
        with:
          components: "rustfmt clippy"

      - name: Rust fmt
        working-directory: apps/rust-service
        run: cargo fmt --all -- --check

      - name: Rust lint
        working-directory: apps/rust-service
        run: cargo clippy --all-targets --all-features -- -D warnings

これでセットアップ処理が共通化され、ワークフローの定義が簡潔になりました。
step 1. では約 250 行あったワークフロー定義が、step 2. では約 150 行に削減されました。

Step3. paths-filter で必要な job のみ実行

並列化は完了しましたが、まだ改善の余地があります。
例えば、Node.js のソースのみに変更が加えられた場合でも、Rust の CI が実行されてしまいます。
このような無駄な CI の実行を避けるために、変更されたファイルに応じて必要な job のみを実行するようにしましょう。
dorny/paths-filter を使用して、変更されたファイルに応じて必要な job のみを実行するようにします。

まず、ワークフローの最初に paths-filter を使用して、変更されたファイルに基づいてフィルターを定義します。

jobs:
  change-detection:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read

    outputs:
      node-changed: ${{ steps.changes.outputs.node }} # Node.js 関連の変更
      node-deps-changed: ${{ steps.changes.outputs['node-deps'] }} # Node.js 依存関係の変更
      rust-changed: ${{ steps.changes.outputs.rust }} # Rust 関連の変更
      rust-deps-changed: ${{ steps.changes.outputs['rust-deps'] }} # Rust 依存関係の変更
      github-actions-changed: ${{ steps.changes.outputs['github-actions'] }} # GitHub Actions 関連の変更
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Check changed files
        uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
        id: changes
        with:
          filters: |
            node:
              - 'apps/node-service/**'
              - 'apps/web/**'
              - 'biome*.json*'
              - 'tsconfig*.json'
              - 'package*.json'
              - 'pnpm-lock.yaml'

            rust:
              - 'apps/rust-service/**'
              - 'Cargo.*'
              - 'rust-toolchain.toml'

            node-deps:
              - 'package*.json'
              - 'pnpm-lock.yaml'

            rust-deps:
              - 'Cargo.*'
              - 'rust-toolchain.toml'

            github-actions:
              - '.github/workflows/**'
              - '.github/actions/**'
              - 'scripts/**'
              - '.github/scripts/**'

各 job 側は以下のように変更します。
jobs セクションで needsif を使用して、変更されたファイルに基づいて必要な job のみを実行するようにします。

jobs:
  # 省略...

  node-license-check:
    needs: change-detection
    if: needs.change-detection.outputs.node-deps-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true' # Node.js 関連の依存関係または GitHub Actions に変更があった場合に実行

    name: "[node] License Check"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Generate licenses.json
        run: pnpm run license:gen

      - name: Check licenses
        run: pnpm run license:check

  node-lint:
    needs: change-detection
    if: needs.change-detection.outputs.node-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true' # Node.js 関連のコードまたは GitHub Actions に変更があった場合に実行

    name: "[node] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Lint
        run: pnpm run lint

これで、変更されたファイルに基づいて必要な job のみが実行されるようになり、無駄な CI の実行を避けることができます。
特に、今回のようにモノレポ構成の場合、変更内容に応じて特定の言語やサービスの CI のみを実行することで、CI 全体の実行時間を短縮できる場合があります。

変更がドキュメントのみの場合、CI はスキップされます。

Step4. Merge Gate パターンで PR のマージを制御

おまけとして、Merge Gate パターンを使用して、CI が成功した場合にのみ PR をマージできるようにする方法を紹介します。

GitHub のブランチ保護ルールによって、特定のステータスチェックが成功した場合にのみ PR をマージできるように設定しているプロジェクトは多いと思います。
しかし、job を細かく分割している場合、すべての job をステータスチェックに追加すると、ブランチ保護の設定が煩雑になってしまいます。
CI 追加時の設定漏れも発生しやすくなります。

そこで、 Merge Gate パターンを使用して、すべての job の結果を集約する専用の job を作成し、その job のみをステータスチェックに追加します。

Merge Gate とは?

プルリクエストをマージして良いかどうかを判断するためのゲートキーパーのような役割を指します。
GitHub 公式の用語ではありませんが、今回はこの手法を Merge Gate パターンと呼びます。

以下のように、すべての job の結果を集約し、いずれかの job が失敗またはキャンセルされた場合に Merge Gate job を追加します。

jobs:
  # 省略...

  merge-gate:
    permissions:
      contents: read
    name: "Merge Gate"
    runs-on: ubuntu-latest
    if: ${{ always() }}
    needs:
      - change-detection
      - node-license-check
      - node-lint
      - node-typecheck-and-build
      - node-test
      - node-test-e2e
      - rust-license-check
      - rust-lint
      - rust-build
      - rust-test

    steps:
      - name: Summary
        run: |
          echo "=== job results ==="
          echo "change-detection:          ${{ needs.change-detection.result }}"
          echo "node-license-check:        ${{ needs.node-license-check.result }}"
          echo "node-lint:                 ${{ needs.node-lint.result }}"
          echo "node-typecheck-and-build:  ${{ needs.node-typecheck-and-build.result }}"
          echo "node-test:                 ${{ needs.node-test.result }}"
          echo "node-test-e2e:             ${{ needs.node-test-e2e.result }}"
          echo "rust-license-check:        ${{ needs.rust-license-check.result }}"
          echo "rust-lint:                 ${{ needs.rust-lint.result }}"
          echo "rust-build:                ${{ needs.rust-build.result }}"
          echo "rust-test:                 ${{ needs.rust-test.result }}"

      - name: Gate (fail on failure/cancelled)
        shell: bash
        run: |
          set -euo pipefail

          fail=0

          check () {
            local name="$1"
            local result="$2"
            case "$result" in
              success|skipped)
                echo "OK  - $name ($result)"
                ;;
              *)
                echo "NG  - $name ($result)"
                fail=1
                ;;
            esac
          }

          check "change-detection"         "${{ needs.change-detection.result }}"
          check "node-license-check"       "${{ needs.node-license-check.result }}"
          check "node-lint"                "${{ needs.node-lint.result }}"
          check "node-typecheck-and-build" "${{ needs.node-typecheck-and-build.result }}"
          check "node-test"                "${{ needs.node-test.result }}"
          check "node-test-e2e"            "${{ needs.node-test-e2e.result }}"
          check "rust-license-check"       "${{ needs.rust-license-check.result }}"
          check "rust-lint"                "${{ needs.rust-lint.result }}"
          check "rust-build"               "${{ needs.rust-build.result }}"
          check "rust-test"                "${{ needs.rust-test.result }}"

          if [ "$fail" -ne 0 ]; then
            echo "Merge gate failed."
            exit 1
          fi

          echo "Merge gate passed."

リポジトリ設定の Require status checks to pass には Merge Gate を設定しておきます。

PRを作成すると、特定の job に失敗した場合は以下のように Merge Gate job が失敗し、PR のマージがブロックされます。

すべての job が成功した場合は Merge Gate job も成功し、PR のマージが可能になります。

これで、job の追加や変更があっても Merge Gate job のみがステータスチェックに追加されていればよいため、
PR のマージ制御が簡単になりました。

これで終わりでも良いのですが、 merge-gate 自体が複雑になってしまっているため、
TypeScript で記述してより保守性を高めましょう。

.github/scripts/merge-gate.ts

type NeedResult = "success" | "failure" | "cancelled" | "skipped" | string;

type NeedsObject = Record<
  string,
  {
    result?: NeedResult;
    outputs?: Record;
  }
>;

function parseNeedsJson(): NeedsObject {
  const raw = process.env.NEEDS_JSON;
  if (!raw) {
    throw new Error(
      "NEEDS_JSON is not set. Pass env: NEEDS_JSON: ${{ toJson(needs) }}"
    );
  }
  try {
    return JSON.parse(raw) as NeedsObject;
  } catch (e) {
    throw new Error(`Failed to parse NEEDS_JSON: ${(e as Error).message}`);
  }
}

function main() {
  const needs = parseNeedsJson();

  const targets = Object.keys(needs);
  if (targets.length === 0) {
    throw new Error(
      "No jobs found in NEEDS_JSON. Check merge-gate job's 'needs:' in the workflow."
    );
  }

  let failed = false;

  console.log("=== job results ===");
  for (const name of targets) {
    const result = needs[name]?.result ?? "unknown";
    console.log(`${name}: ${result}`);

    // success と skipped はOK、それ以外はNG(failure/cancelled/unknown 等)
    if (result !== "success" && result !== "skipped") {
      console.error(`NG  - ${name} (${result})`);
      failed = true;
    } else {
      console.log(`OK  - ${name} (${result})`);
    }
  }

  if (failed) {
    console.error("Merge gate failed.");
    process.exit(1);
  }

  console.log("Merge gate passed.");
}

main();

merge-gate を以下のように書き換えます。

jobs:
  # 省略...

  merge-gate:
    permissions:
      contents: read
    name: "Merge Gate"
    runs-on: ubuntu-latest
    if: ${{ always() }}
    needs:
      - change-detection
      - node-license-check
      - node-lint
      - node-typecheck-and-build
      - node-test
      - node-test-e2e
      - rust-license-check
      - rust-lint
      - rust-build
      - rust-test

    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: "24" # デフォルトだと v20 で TS を実行できないため明示指定(JS で実行する場合はなくても良い)

      - name: Gate (fail on failure/cancelled)
        env:
          NEEDS_JSON: ${{ toJson(needs) }}
        run: node .github/scripts/merge-gate.ts

これで needs の管理のみで Merge Gate job をメンテナンスできるようになりました。
もし、 needs の追加漏れを防止したい場合は、期待する job 名のリストを受け取り、
スクリプト内でそれらの job がすべて含まれているかバリーデーションするロジックを追加すると良いでしょう。

完成したワークフロー

最終的には以下のようなワークフローになります。

name: "CI"

on:
  pull_request:
    branches:
      - master
      - develop
  push:
    branches:
      - master
      - develop

jobs:
  change-detection:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read

    outputs:
      node-changed: ${{ steps.changes.outputs.node }}
      node-deps-changed: ${{ steps.changes.outputs['node-deps'] }}
      rust-changed: ${{ steps.changes.outputs.rust }}
      rust-deps-changed: ${{ steps.changes.outputs['rust-deps'] }}
      github-actions-changed: ${{ steps.changes.outputs['github-actions'] }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Check changed files
        uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
        id: changes
        with:
          filters: |
            node:
              - 'apps/node-service/**'
              - 'apps/web/**'
              - 'biome*.json*'
              - 'tsconfig*.json'
              - 'package*.json'
              - 'pnpm-lock.yaml'

            rust:
              - 'apps/rust-service/**'
              - 'Cargo.*'
              - 'rust-toolchain.toml'

            node-deps:
              - 'package*.json'
              - 'pnpm-lock.yaml'

            rust-deps:
              - 'Cargo.*'
              - 'rust-toolchain.toml'

            github-actions:
              - '.github/workflows/**'
              - '.github/actions/**'
              - 'scripts/**'

  node-license-check:
    needs: change-detection
    if: needs.change-detection.outputs.node-deps-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'

    name: "[node] License Check"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Generate licenses.json
        run: pnpm run license:gen

      - name: Check licenses
        run: pnpm run license:check

  node-lint:
    needs: change-detection
    if: needs.change-detection.outputs.node-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'

    name: "[node] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Lint
        run: pnpm run lint

  node-typecheck-and-build:
    needs: change-detection
    if: needs.change-detection.outputs.node-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'

    name: "[node] Typechecking and Building"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Typecheck
        run: pnpm run typecheck

      - name: Build
        run: pnpm run build

  node-test:
    needs: change-detection
    if: needs.change-detection.outputs.node-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'

    name: "[node] Testing"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Test
        run: pnpm run test

  node-test-e2e:
    needs: change-detection
    if: needs.change-detection.outputs.node-changed == 'true' || needs.change-detection.outputs.rust-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'

    name: "[node] E2E Testing"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-node

      - name: Install Playwright browsers
        run: pnpm --filter web exec playwright install --with-deps chromium

      - name: E2E Test
        run: pnpm run test:e2e

  rust-license-check:
    needs: change-detection
    if: needs.change-detection.outputs.rust-deps-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'
    name: "[rust] License Check"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-rust

      - name: Install cargo-deny
        run: cargo install cargo-deny

      - name: Check Rust licenses
        working-directory: apps/rust-service
        run: cargo deny check licenses

  rust-lint:
    needs: change-detection
    if: needs.change-detection.outputs.rust-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'
    name: "[rust] Linting"
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-rust
        with:
          components: "rustfmt clippy"

      - name: Rust fmt
        working-directory: apps/rust-service
        run: cargo fmt --all -- --check

      - name: Rust lint
        working-directory: apps/rust-service
        run: cargo clippy --all-targets --all-features -- -D warnings

  rust-build:
    needs: change-detection
    if: needs.change-detection.outputs.rust-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'
    name: "[rust] Build"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-rust

      - name: Rust build
        working-directory: apps/rust-service
        run: cargo build --all-features --workspace --release

  rust-test:
    needs: change-detection
    if: needs.change-detection.outputs.rust-changed == 'true' || needs.change-detection.outputs.github-actions-changed == 'true'
    name: "[rust] Test"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-rust

      - name: Rust test
        working-directory: apps/rust-service
        run: cargo test --all-features --workspace

  merge-gate:
    permissions:
      contents: read
    name: "Merge Gate"
    runs-on: ubuntu-latest
    if: ${{ always() }}
    needs:
      - change-detection
      - node-license-check
      - node-lint
      - node-typecheck-and-build
      - node-test
      - node-test-e2e
      - rust-license-check
      - rust-lint
      - rust-build
      - rust-test

    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: "24" # デフォルトだと v20 で TS を実行できないため明示指定(JS で実行する場合はなくても良い)

      - name: Gate (fail on failure/cancelled)
        env:
          NEEDS_JSON: ${{ toJson(needs) }}
        run: node .github/scripts/merge-gate.ts

まとめ

これにて CI の高速化とメンテナンス性の向上が完了しました。
CI の高速化とメンテナンス性向上のために以下の手法を紹介しました。

  • job の並列化

  • Composite Action で共通化

  • paths-filter で必要な job のみ実行

  • Merge Gate パターンで PR のマージを制御

GitHub Actions は開発体験を大きく向上させる強力なツールです。
ぜひ、今回紹介した手法を活用して、効率的で快適な GitHub Actions ライフを!!

murakamisのブログ