Pythonのmonorepoの共通Lint & Test用Github Action workflow


# はじめに

複数のPython projectを1つのリポジトリで管理する場合、CIの設定をどうするか悩むことがあると思います。
通常は、各プロジェクトごとにworkflowを用意し、`paths`に対応プロジェクトを指定することで、特定プロジェクトに対してのみworkflowを実行することになります。
ただ、個々のプロジェクトで使用しているライブラリやpythonのバージョンは違うが、Lintやテストのworkflowは、ほぼ同じ内容になることが多いです。
そのため、この方法では、同じ内容のworkflowがいくつもできあがってしまいます。

```yaml
on:
  push:
    paths:
      - 'project-1/**' # project-1 に変更があった場合
```

この記事では、以下の要望を満たすworkflowを作成します。

- 複数のPython projectを1つのリポジトリで管理している。root直下に、複数のディレクトリが存在し、それぞれがプロジェクトを表す。
  - 例
    - `project-1/`
    - `project-2/`
- lintやテストなど共通のworkflowを1つにまとめたい
- 各プロジェクトごとに使いたいライブラリやpythonのバージョンは異なる。そのため、各プロジェクト直下にある`pyproject.toml`に応じてライブラリやpythonのバージョンを変更してほしい
- 複数のプロジェクトが同時に変更されることもある。

# 方法

以下のリポジトリの`.github/workflows/python-lint-test.yaml` に上記要件を満たすような完成形のworkflowがあります。

[GitHub - haru-256/blog-python-multiple-project-20250405](https://github.com/haru-256/blog-python-multiple-project-20250405)

```yaml
jobs:
  target_projects:
    name: Find target Projects
    permissions:
      contents: read
    runs-on: ubuntu-latest
    outputs:
      projects: ${{ steps.get-projects.outputs.projects }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Get changed directories
        id: changed-files
        uses: tj-actions/changed-files@6cb76d07bee4c9772c6882c06c37837bf82a04d3 # v46
        with:
          matrix: true # 後続のjobでmatrixで使うために。ファイルがjson形式で出力される
          dir_names: true # uniqueディレクトリ名を取得
          dir_names_max_depth: 1 # ディレクトリ名の深さを指定
          files_ignore: |
            .github/**
      - name: Get Python projects
        uses: actions/github-script@v7
        id: get-projects
        with:
          script: |
            const fs = require('node:fs');
            // If target_projects input is provided, use it
            dispatch_inputs = "${{ github.event.inputs.target_projects }}";
            if (dispatch_inputs !== "") {
              // TODO: validate the input
              core.setOutput('projects', JSON.stringify(dispatch_inputs));
            } else { // If no input is provided, get directories from changed files
              const inputs = JSON.parse(${{ toJSON(steps.changed-files.outputs.all_changed_and_modified_files) }});
              // Filter out directories that contain pyproject.toml and Makefile
              const paths = inputs.filter(path => {
                if (
                  fs.statSync(path).isDirectory()
                  && fs.existsSync(`${path}/pyproject.toml`)
                  && fs.existsSync(`${path}/Makefile`)
                ) {
                  return true;
                }
                return false;
              });
              core.setOutput('projects', JSON.stringify(paths));
            }
      - name: Print changed directories
        run: |
          echo "Changed directories: ${{ steps.get-projects.outputs.projects }}"
          echo "::debug::Changed directories: ${{ steps.get-projects.outputs.projects }}"

  python-lint-and-test:
    name: Python Lint and Test
    needs: target_projects
    if: ${{ needs.target_projects.outputs.projects != '' && toJson(fromJson(needs.target_projects.outputs.projects)) != '[]' }}
    strategy:
      matrix:
        project: ${{ fromJson(needs.target_projects.outputs.projects) }}
    runs-on: ubuntu-latest
    concurrency:
      group: ${{ github.workflow }}-${{ matrix.project }}
      cancel-in-progress: true
    defaults:
      run:
        working-directory: ${{ matrix.project }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-dependency-glob: "${{ matrix.project }}/pyproject.toml"
          cache-suffix: "${{ matrix.project }}"
      - name: Install the project
        run: make install
      - name: Lint
        run: make lint
      - name: Test
        run: make test
```

## 概要

Github Action: [tj-actions/changed-files](https://github.com/tj-actions/changed-files)を使用して、各event(`push`, `pull_request`) ごとに適切なcommit前後で、変更されたディレクトリを取得します。その後、大賞プロジェクトを推定します。
そして、上記のjobの後に、プロジェクトディレクトリをworking directoryとして、Lint や Testを実行します。これにより、working directory配下の`pyproject.toml`に応じてライブラリやpythonバージョンを変更して、workflowを実行できmるようになります。

## 具体的な流れ

ここではworkflowの中でもキモとなる `target_projects` というjobの中身を説明します。

`target_projects` では、Lintとテストを実行する対象のプロジェクト = ディレクトリを取得します。
その後のstepで取得したディレクトリをworking directoryにするため、ディレクトリ名を`projects`に格納します。

このstepは、以下のような流れで処理を行います。

1. `tj-actions/changed-files`で変更されたファイルのユニークディレクトリ名を取得する
2. 各ディレクトリ直下に`pyproject.toml`と`Makefile`が存在するかを確認し、存在するディレクトリのみをcontext `projects`に格納する。

### 1.  `tj-actions/changed-files`で変更されたファイルのユニークディレクトリ名を取得

`tj-actions/changed-files`を使用して、変更されたファイルのユニークディレクトリ名を取得します。
このとき、以下の変数を指定します。

- `matrix`: 後続のjobでmatrixで使うために。ファイルがjson形式で出力される。変更されたプロジェクトが複数ある場合、matrixで指定することで、後続のjobでそれぞれのプロジェクトに対してworkflowを実行できるようになります。
- `dir_names`: ファイル名ではなく、ユニークディレクトリ名を取得するために指定します。
- `dir_names_max_depth`: ディレクトリ名の深さを指定します。`1`を指定することで、直下のディレクトリ名のみを取得します。 e.g. `project-1`や`project-2`のように、直下のディレクトリ名を取得します。
- `files_ignore`: 除外するファイルを指定します。ここでは、`.github`ディレクトリを除外しています。

```yaml
        with:
          matrix: true # 後続のjobでmatrixで使うために。ファイルがjson形式で出力される
          dir_names: true # uniqueディレクトリ名を取得
          dir_names_max_depth: 1 # ディレクトリ名の深さを指定
          files_ignore: |
            .github/**
```

### 2. 各ディレクトリ直下に`pyproject.toml`と`Makefile`が存在するかを確認

`pyproject.toml`と`Makefile`が存在するディレクトリのみを取得します。
このとき、bash scriptを書いても良いのですが、[actions/github-script](https://github.com/actions/github-script)を使用することで、jsが使用でき、より簡潔に書くことができます。

`actions/github-script` は以下の記事が参考になります。

- [GitHub ActionsのCI/CDを華やかにするactions/github-scriptを使いこなす](https://zenn.dev/h_sugawara/articles/24488d01fede71)

### 取得したディレクトリをworking directoryとして、Lint や Testを実行

`target_projects` jobで取得したprojectsを、`python-lint-and-test` jobで使用します。
このとき、複数projectが同時に変更されることもあるため、matrixを使用して、各projectに対してworkflowを実行します。
`tj-actions/changed-files`でinput: `matrix`をtrueにしておくと、出力がjson文字列形式で出力されるため、`fromJson`を使用しつつ、matrixに指定することができます(公式の[例](https://github.com/tj-actions/changed-files/blob/main/.github/workflows/matrix-example.yml#L37))。

```yaml
  python-lint-and-test:
    name: Python Lint and Test
    needs: target_projects
    if: ${{ needs.target_projects.outputs.projects != '' && toJson(fromJson(needs.target_projects.outputs.projects)) != '[]' }}
    strategy:
      matrix:
        project: ${{ fromJson(needs.target_projects.outputs.projects) }}
    runs-on: ubuntu-latest
    concurrency:
      group: ${{ github.workflow }}-${{ matrix.project }}
      cancel-in-progress: true
    defaults:
      run:
        working-directory: ${{ matrix.project }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-dependency-glob: "${{ matrix.project }}/pyproject.toml"
          cache-suffix: "${{ matrix.project }}"
      - name: Install the project
        run: make install
      - name: Lint
        run: make lint
      - name: Test
        run: make test
```
# まとめ

この記事では、以下のことを紹介しました。

- 複数のPython projectを1つのリポジトリで管理する場合、CIの設定をどうするか悩むことがある
- 各プロジェクトごとに使いたいライブラリやpythonのバージョンは異なる。そのため、各プロジェクト直下にある`pyproject.toml`に応じてライブラリやpythonのバージョンを変更してほしい
- `tj-actions/changed-files`で変更されたファイルを取得しつつ、それらを `matrix` 指定することで複数projectでLintとテスト をそれぞれの環境で実施することができる