💡

Github Actions の動的なワークフローの作り方

2024/02/04に公開

はじめに

この記事では Gihub actionsの動的なワークフローの作り方を紹介します。ワークフローを動的な構成にしておくと、規模の大きなモノリポの github actionsの保守がやりやすくなります。

動的なワークフロー

モノリポ構成の場合、リポジトリには複数のプロジェクトが含まれます。新規プロジェクトの追加や不要なプロジェクトを削除した場合、通常はワークフローの修正が必要ですが、ワークフローの書き方を工夫することで、プロジェクトの増減に対して動的に実行できるワークフローを作ることができます。

ワークフローで実現したいこと

  1. モノリポ内のディレクトリによって区切られた任意のプロジェクトに対して、ビルドを実行できること
  2. 変更のあったプロジェクトに対してのみビルドを実行すること

1はワークフローの記述を簡潔にし、複数プロジェクトのビルドに統一性を持たせたい場合に重要になります。任意のプロジェクトに対するビルド実行は、ジョブを Matrix Jobとして構成することで実現できます。

2は不要なビルドを減らしビルド時間短縮とコスト削減のために重要です。変更検知にはいくつかの方法がありますが、ここでは paths-filter を使った方法を紹介します。

サンプルワークフロー

サンプルリポジトリに、動的なワークフローを作成しています。
このサンプルでは、リポジトリのルート直下に配置した proj で始まる名称のディレクトリをプロジェクトとして認識し、変更を検知し、変更があった場合のみビルド(makeコマンド)を実行します。

dynamic-build.yaml
name: Dynamic Workflow
on:
  workflow_dispatch:

jobs:
  static-changes:
    outputs:
      projects: ${{ steps.filter.outputs.changes }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            proj1: proj1/**
            proj2: proj2/**

  static-build:
    needs: static-changes
    if: needs.static-changes.outputs.projects != '[]'
    strategy:
      matrix:
        project: ${{ fromJSON(needs.static-changes.outputs.projects) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building ${{ matrix.project }}"
      - run: make
        working-directory: ${{ matrix.project }}

  dynamic-changes:
    environment:
      name: debug
    outputs:
      projects: ${{ steps.changes.outputs.projects }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          list-files: json
          filters: |
            changed: 'proj*/**'
      - id: changes
        env:
          changed: ${{steps.filter.outputs.changed_files}}
        run: |
          projects="$( echo $changed | jq -r '.[]' | cut -d'/' -f1 | sort | uniq | jq -s -R -c 'split("\n") | map(select(. != ""))' )"
          echo "projects=${projects}" >> $GITHUB_OUTPUT

  dynamic-build:
    environment:
      name: debug
    needs: dynamic-changes
    if: needs.dynamic-changes.outputs.projects != '[]'
    strategy:
      matrix:
        project: ${{ fromJSON(needs.dynamic-changes.outputs.projects) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building ${{ matrix.project }}"
      - run: make
        working-directory: ${{ matrix.project }}

サンプル中の static-changes, static-build ジョブは Matrixは使っているものの静的なワークフローであるためプロジェクトの増減した場合 paths-filterのフィルタ条件を変更する必要があります。
一方で、dynamic-changes, dyamic-buildジョブは動的なワークフローとして構成しているため、プロジェクトを増減しても変更が不要です。

変更検知: dynamic-changes ジョブ

変更検知には git diff などで自前で記述することもできますが、ここでは変更検知によく使われる paths-filter action を応用して動的に変更を検知しています。

  dynamic-changes:
    environment:
      name: debug
    outputs:
      projects: ${{ steps.changes.outputs.projects }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          list-files: json
          filters: |
            changed: 'proj*/**'
      - id: changes
        env:
          changed: ${{steps.filter.outputs.changed_files}}
        run: |
          projects="$( echo $changed | jq -r '.[]' | cut -d'/' -f1 | sort | uniq | jq -s -R -c 'split("\n") | map(select(. != ""))' )"
          echo "projects=${projects}" >> $GITHUB_OUTPUT

処理としては paths-filter により変更のあったファイル一覧を取得し、ファイル一覧からディレクトリ名を取り出し、最後に変更のあったディレクトリをJSON配列形式で job outputsに出力しています。
ポイントは paths-filterの list-files オプションです。このオプションを有効にすると、{フィルタ名}_filesという step outputsに変更のあったファイル一覧が出力されるようになります。
変更一覧を jq や cutコマンドなどで変更のあったディレクトリの配列に変換し、projects という名前で出力しています。

任意プロジェクトのビルド: dynamic-build ジョブ

dynamic-buildジョブでは dynamic-changesジョブで出力した projectsのそれぞれに対してビルドを実行します。
ジョブの strategy.matrix で dynamic-changes.outputs.projects を入力とすることで、dynamic-changes.outputs.projects 配列の要素ごとに繰り返しジョブを実行します。

  dynamic-build:
    environment:
      name: debug
    needs: dynamic-changes
    if: needs.dynamic-changes.outputs.projects != '[]'
    strategy:
      matrix:
        project: ${{ fromJSON(needs.dynamic-changes.outputs.projects) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building ${{ matrix.project }}"
      - run: make
        working-directory: ${{ matrix.project }}

Note: ここでは 一律で makeを実行としていますが、実際にはプロジェクトが採用している言語やビルドシステムに応じたビルドコマンドや対応した github actionを実行することになります。ポリグロットなモノリポの場合は、言語やビルドシステムごとにビルドジョブを複数種類用意しておくとビルド処理の最適化がしやすい一方で、あえて makeや Earthlyでビルドを統一するとプロジェクトごとのビルドの自由度を高くできそうです。

まとめ

モノリポ構成は複数のプロジェクトをまとめて扱えるため便利ですが、規模が大きくなりプロジェクトの数が増えてると、ワークフローの修正忘れやプロジェクトごとのビルド手法の差異といったメンテナンスコストも高くなってきます。
扱うプロジェクトの数が一定数を超えた大規模なモノリポの場合、動的なワークフロー構成にしておくと、メンテンナンスコストの低減や、プロジェクト間のビルドに統一性を持たせられるといったメリットが生まれます。

参考文献

以下の記事を参考にしました。

Discussion