1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-06

はじめに

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

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

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を使用して、各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.tomlMakefileが存在するかを確認し、存在するディレクトリのみを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-1project-2のように、直下のディレクトリ名を取得します。
  • files_ignore: 除外するファイルを指定します。ここでは、.githubディレクトリを除外しています。
        with:
          matrix: true # 後続のjobでmatrixで使うために。ファイルがjson形式で出力される
          dir_names: true # uniqueディレクトリ名を取得
          dir_names_max_depth: 1 # ディレクトリ名の深さを指定
          files_ignore: |
            .github/**

2. 各ディレクトリ直下にpyproject.tomlMakefileが存在するかを確認

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

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

取得したディレクトリを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に指定することができます(公式の)。

  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とテスト をそれぞれの環境で実施することができる
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?