プライベートのPythonライブラリ開発で設定しているGitHub Actionsを一通りしっかりまとめてみた

※この記事はQiita エンジニアフェスタ 2022「 GitHub Actionsの自分流の使い方をシェアしよう 」用の記事となります。

https://qiita.com/official-events/14b94a693d6153857db4

趣味（技術的盆栽）でちまちまと作っているPythonライブラリに対して設定しているGitHub Actionsの各種設定について説明・振り返りなどしていきます。Pythonライブラリの作成を検討している方で少しは参考になれば幸いです。

# 対象のライブラリ

以下のリポジトリのライブラリとなります。「Pythonでフロントエンドを書けるようにしてみたい」「細かい調整も自分でできるようにしたい」「HTMLとしてだけでなくJupyter notebook上でも使いたい」「将来的にはプロット制御などでも役立てたい」といった感じで少しずつ書いていっています。

https://github.com/simon-ritchie/apysc

# 設定しているGitHub Actions

Pythonライブラリなのでpipで扱えるようにするためPyPIへデプロイする必要があり、その辺のCI/CD的に使っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/6eb8b73f-cb4c-7461-7a5f-7d146b02e0f5.png)

対象のYAMLファイル:

https://github.com/simon-ritchie/apysc/blob/main/.github/workflows/deploy_to_pypi.yml

やっていることとしては以下のような点があります。後の節でそれぞれ細かく触れていきます。

- 各ジョブ間で使いまわす定数値の設定
- 各ジョブ上で処理時間を短縮するためのPython環境（ライブラリ等）のキャッシュ（環境が更新されている場合にのみ実行）
- READMEのバッジの更新
- flake8によるチェック
- mypyによるチェック
- Pyrightによるチェック
- numdoclintによるチェック
- 各Pythonバージョンでの単体テストの実行
- 単体テストのカバレッジの取得と保存
- doctestの実行
- 英語と日本語のそれぞれのドキュメントに対するE2Eテスト
- ドキュメント内のコードブロックの抽出と実行を行いエラーが出ないことのチェック
- テスト用に各機能ごとに追加しているテストプロジェクトのコードの実行と結果のHTMLに対するE2Eテスト
- docstringのReferencesに記載されているドキュメントの有無確認
- 諸々のチェックを通ったらPyPIへのアップロード
- PyPIへアップロードされたバージョンのパッケージのインストールと少し動かしてみてエラーにならないことの確認
- リリースノートの登録

また、デプロイ以外では以下のものも使わせていただいています。

- CodeQL
- secretlint
- GitHub Pagesへのデプロイ（Pagesを設定すれば自動で設定されるものを利用しています）

以下の節で上記のリストについてそれぞれ見ていきます。

# 各ジョブ間で使いまわす定数値の設定

最初にYAML内で各ジョブ内で何度も参照する定数値を設定しています。同じジョブ内であれば環境変数的に設定すれば対応が効きますがジョブをまたぐと環境変数は参照できないので、各ジョブ間に値を渡す形で設定しています。

各ジョブ上でハードコーディングしていると修正漏れなどをやらかしがちなのでDRY原則的に定義は1か所にまとめています。

以下の赤枠部分で行っています。各ジョブの前に、一番最初に実行しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/4cd9b993-49a3-6cf6-ef09-88ff0b69c062.png)

現在は以下のような値を定数として持っています。

- 使用する各Pythonバージョン
- バッジで使用する色
- バッジで使用するラベルやキー名

Pythonバージョンについてはパッチバージョンに関しても指定しています。パッチバージョンを指定しない形だと最新のパッチバージョンのものを使ってくれるのでこれはこれで便利なのですが後述する環境のキャッシュでパッチバージョンも含めて指定する必要があったためパッチバージョンも含める形で指定しています。今のところ定数へのバージョンの指定はハードコーディングとなっていますが、将来的には最新のパッチバージョンが自動で定数に設定されるように調整するかもしれません。

YAMLの記述としては以下のようになっています。outputsのセクションに各定数値を設定しています。stepsのセクションはこのジョブでは使わないのですが空だとGitHub Actionsでエラーになってしまうため無駄にechoするだけ処理を実行しています。

```yaml
  ...
  SetGlobalConstants:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      PYTHON_37_VERSION: 3.7.13
      PYTHON_38_VERSION: 3.8.12
      PYTHON_39_VERSION: 3.9.12
      PYTHON_310_VERSION: 3.10.4
      CHECKING_BADGE_COLOR: FFAA00
      PASSING_BADGE_COLOR: 0088FF
      RUNNING_OR_FAILING_STATUS: running or failed
      PASSING_STATUS: passing
      PASSING_LINTS_BADGE_NAME: passing_lints
      PASSING_LINTS_BADGE_LABEL: 'passing lints'
      PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_NAME: passing_unit_test_python_versions
      PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_LABEL: 'passing unit tests Python versions'
    steps:
      - run: echo 'Setting constans.'
    ...
```

このようにoutputsに値を設定しておくことで他の各ジョブで値を参照できるようになります。参照するにはそのジョブのneedsの箇所に定数設定のジョブを指定する必要があります。その後は`${{ needs.<ジョブ名>.outputs.<定数などの名前> }}`といった書き方で設定値にアクセスすることができます。今回の例で言うと`needs: SetGlobalConstants`という指定が必要になるのと、定数値へのアクセスには`${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}`といった記述で定数値にアクセスできます。

```yaml
  ...
  CreateCache:
    needs: SetGlobalConstants
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [
          '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}',
        ]
    timeout-minutes: 20
    ...
```

※この辺は以前記事にもしたので必要に応じてそちらもご参照ください。

https://qiita.com/simonritchie/items/1104f6339cad72644160

# 各ジョブ上で処理時間を短縮するためのPython環境のキャッシュ

Dockerイメージとかを使っても良いのかもしれませんが、今のところはGitHub Actions側で提供してくれているキャッシュの機能を使ってPython実行環境（Pythonライブラリなど）のキャッシュを行っています。

ジョブの先頭の方で各Pythonバージョンごとにキャッシュを行い、各ジョブでPythonを使う箇所でキャッシュから環境を復元しています。

同じ環境で既にキャッシュが存在すればキャッシュ処理はスキップされます（GitHub Actions上で日を跨いだりしても一定期間アクセスが無くなるまで保持されるようです）。
また、push時に環境設定が変わっている（Pythonバージョンが変わったりライブラリの指定が変動したなど）場合には再度キャッシュの保存が実行されるようにしています。

GitHub Actionsのキャッシュ関係のドキュメントを以前読んでいたところ、サンプルがライブラリパッケージのダウンロードの部分になっていたのですが、前試した感じGitHub Actions上のネットワークが十分速いのでそのダウンロード部分をキャッシュしてもあまり処理時間の短縮に繋がりませんでした。

どちらかというとダウンロード後のパッケージのインストールなどで処理時間がかかっていたため、インストール処理実行後のパッケージディレクトリをダイレクトにキャッシュしています（こちらの場合にはぐぐっと処理時間が短縮されました）。

ただしライブラリパッケージによるコマンドなどはそのままだとパス設定などが無くなる？ようなのでエイリアス設定などをGitHub Actions上で行う形にしています。

フロー上では以下の赤枠部分が該当します。定数設定後に処理を行うようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/89231a5c-73b7-4048-4e68-c5797372a9b2.png)

Python環境のキャッシュを作成するジョブは以下のようになっています（1つ1つ説明を加えていきます）。

```yaml
  CreateCache:
    needs: SetGlobalConstants
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [
          '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}',
        ]
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set the Python version
        uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python-version }}
      - name: Set the site package path to the environment variables
        run: python -c "import site; print(f'site-packages-path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
      - name: Set the Python package cache
        uses: actions/cache@v2
        id: pip_cache
        with:
          path: ${{ env.site-packages-path }}
          key: ${{ runner.os }}-pip-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
      - name: Set the build-essential if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        run: sudo apt install build-essential
      - name: Install each Python library if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        run: pip install -r requirements.txt
      - name: Set the bin cache if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        uses: actions/cache@v2
        with:
          path: /opt/hostedtoolcache/Python/${{ matrix.python-version }}/x64/bin/
          key: ${{ runner.os }}-bin-cache-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
```

まずはジョブの記述内で定数値を参照しているので、ジョブ内で定数値にアクセスできるようにneedsの箇所で定数設定のジョブを指定しています。他のジョブでも大半はこの指定がされています。

```yaml
    needs: SetGlobalConstants
```

以下の部分では実行する各Pythonバージョンを指定しています。matrixで指定した各値は並列化されてGitHub Actions上で実行されます。プログラムで言うと並列実行される形でのfor文に指定する配列的な挙動をします。定数設定のジョブで設定した各Pythonバージョンの文字列を配列に設定しています。

これでPython3.7～Python3.10までの4環境でキャッシュの作成処理が実行されるようになります。

```yaml
    strategy:
      matrix:
        python-version: [
          '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}',
        ]
```

以下の部分では指定されたPythonバージョンのPythonを設定しています。withのpython-version部分に対象のPythonバージョンを指定することで該当バージョンのPythonを使えるようになります。また、`${{ matrix.python-version }}`といった記述をすることでstrategyのmatrix部分で設定した各Pythonバージョンを参照することができます。余談ですが、他のジョブでもそうなのですが既に世の中にあるactionsを利用させていただくことで非常にシンプルに（短い行数で）ジョブが設定できるのは楽で良いです。

```yaml
      - name: Set the Python version
        uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python-version }}
```

以下の部分ではPythonをコマンド経由で動かして、site-packages（Pythonのインストール済みのライブラリパッケージ）のパスを環境変数に設定しています。インストール済みのライブラリをキャッシュするため必要になります。

siteというビルトインパッケージで取得できるのと、`site-packages-path=パッケージのパス`という形式で環境変数（`$GITHUB_ENV`）へと書きこまれるようにしています。

設定された環境変数へは`${{ env.site-packages-path }}`といったような記述でアクセスができます。

```yaml
      - name: Set the site package path to the environment variables
        run: python -c "import site; print(f'site-packages-path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
```

以下の部分ではキャッシュの復元処理を行っています。後に続く処理でキャッシュが存在するかしないかで処理を分岐させているためこの時点で1度キャッシュを復元しています。以前のデプロイ処理時のキャッシュが残っていればそちらが復元されます。初回実行時や長期間実行しておらずキャッシュが削除済みの場合、その他ライブラリ設定などが変わった場合などはキャッシュがヒットしません。

キャッシュ関係は`actions/cache@v2`のactionsを利用させていただいています。withのpathにキャッシュを復元するパス、withのkeyにキャッシュのキーを設定します。該当するキーが存在すればキャッシュがヒットしたことになります。

キーの`${{ runner.os }}`はキャッシュ環境のOSの文字列となります。基本的に現在Ubuntuのみとなりますが、将来別OSを使いだしたりした場合はキャッシュにヒットしなくなります。

`${{ env.site-packages-path }}`部分は前述の処理で設定したsite-packagesの値です。Pythonバージョンが変わった場合などにはこの指定に準じてキャッシュがヒットしなくなります（site-packagesのパスにはPythonバージョンも含まれます）。

`${{ hashFiles('**/requirements.txt')`はリポジトリ内の各requirements.txt（ライブラリの指定用のファイル）の内容をハッシュ化された文字列を取得する記述です。ライブラリの開発環境のライブラリやライブラリバージョンの指定はrequirements.txtで行っており、もしrequirements.txtの内容が変更になった（ライブラリが追加になったりバージョンが変更になんだ）場合にはこのハッシュ値が変わるためキャッシュにヒットしなくなります。

```yaml
      - name: Set the Python package cache
        uses: actions/cache@v2
        id: pip_cache
        with:
          path: ${{ env.site-packages-path }}
          key: ${{ runner.os }}-pip-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
```

以下の部分ではもしキャッシュがヒットしない場合に一部のライブラリインストールに必要なパッケージのインストールを指定しています。キャッシュが存在すれば処理はスキップされます。キャッシュの有無は`if: steps.pip_cache.outputs.cache-hit != 'true'`といった記述で設定できます（`pip_cache`という部分は対象のstepのid設定によって別の値が必要になります）。

```yaml
      - name: Set the build-essential if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        run: sudo apt install build-essential
```

また、同様にキャッシュが存在しない場合には以下のようにrequirements.txtに指定されている各Pythonライブラリのインストールを行うようにしています。こちらもキャッシュが存在すればスキップされます。

```yaml
      - name: Install each Python library if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        run: pip install -r requirements.txt
```

キャッシュの保存自体は`actions/cache@v2`の記述をしてあればこのジョブの最後に実行されるため、インストールした各Pythonライブラリはキャッシュがなければそのままキャッシュされて次回以降参照できるようになります。

加えてもしキャッシュが存在しなければPythonライブラリのコマンド実行用にbin関係のキャッシュも以下のように行っています。この辺はflake8などでのコマンド実行時に必要になります。

```yaml
      - name: Set the bin cache if there is no site-packages cache
        if: steps.pip_cache.outputs.cache-hit != 'true'
        uses: actions/cache@v2
        with:
          path: /opt/hostedtoolcache/Python/${{ matrix.python-version }}/x64/bin/
          key: ${{ runner.os }}-bin-cache-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
```

※この辺りは以前別途記事にもしているため、必要に応じてそちらもご確認ください。

https://qiita.com/simonritchie/items/d536e5ee59b8e192e1d0

# README上のバッジの実行中のステータスへの更新処理

README上のバッジの更新に関してもGitHub Actions上で行っています。テストカバレッジ・通過テスト数・テストに使用したPythonバージョン（実質サポートPythonバージョン）などが更新されるようにしてあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/d2d8d647-bca9-7ffb-1fb8-d934d7fbe831.png)

更新処理にはBYOB（Bring Your Own Badge）というGitHub Actions用のバッジ更新用のライブラリを使っています。

GitHub Actions上ではジョブ開始時に`running or failed`といったようなラベル＆黄色のステータスが設定され、ジョブ完了後に`passing`といったようなラベル＆青色に変更したりしています。これによってジョブが失敗などしている場合にバッジですぐに確認できるようにしています。

この節ではジョブ開始時の方のバッジ設定について触れていきます。
以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/b0bb9877-06ae-93b1-d15e-800c8a55194e.png)

以下のような記述になっています。ここでは2つのバッジの表示をジョブを動かしている途中（もしくは失敗している）ということが分かるラベルに更新されるようにしています。

```yaml
  UpdateReadmeBadgesToCheckingStatus:
    needs: SetGlobalConstants
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Update each lint badge status to running or failing
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: '${{ needs.SetGlobalConstants.outputs.PASSING_LINTS_BADGE_NAME }}'
          LABEL: '${{ needs.SetGlobalConstants.outputs.PASSING_LINTS_BADGE_LABEL }}'
          STATUS: '${{ needs.SetGlobalConstants.outputs.RUNNING_OR_FAILING_STATUS }}'
          COLOR: ${{ needs.SetGlobalConstants.outputs.CHECKING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Update the passing unit tests Python versions status
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: '${{ needs.SetGlobalConstants.outputs.PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_NAME }}'
          LABEL: '${{ needs.SetGlobalConstants.outputs.PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_LABEL }}'
          STATUS: '${{ needs.SetGlobalConstants.outputs.RUNNING_OR_FAILING_STATUS }}'
          COLOR: ${{ needs.SetGlobalConstants.outputs.CHECKING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

BYOBのライブラリですが、以下のようにwith部分にパラメーターを設定することでバッジ用の設定を更新することができます。

- NAME: README上などでバッジ設定する際などに必要になるIDのような値。例 : `passing_lints`
- LABEL: バッジ上に表示される左側のラベル。例 : `passing lints`
- STATUS: バッジの右側に表示されるラベル。例 : `running or failed`
- COLOR: バッジの色。例 : `FFAA00`
- GITHUB_TOKEN: 固定で`${{ secrets.GITHUB_TOKEN }}`と指定することで、GitHub操作用のトークン値をGitHub Actions上から渡せるのでそのパラメーターを設定しています。

これに加えてREADME.md上で`![](https://byob.yarr.is/simon-ritchie/apysc/passing_lints)`といった記述を行うことでREADME上にバッジを表示することができます（反映されるのに少し時間がかかったりするのでそういった場合には少し待っていると表示されます）。

`simon-ritchie/apysc/`部分はユーザー名とリポジトリ名依存、`passing_lints`部分はBYOBのNAMEで指定した値が必要になります。

これで以下のようにバッジがREADME上に表示されるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/fc5c7d65-6a45-4117-070c-58361dbb9833.png)

※この辺りの件は以下のように以前記事にもしているので必要に応じてご確認ください。

https://qiita.com/simonritchie/items/629a02fc1ad0fd02d267

# flake8によるチェック

デプロイ前にflake8でのチェックを行っています。コーディングスタイルは基本的にPEP8をベースにしているため、チェックはflake8にて行っています。以下の赤枠部分となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/aadebd6b-27ef-9bcc-17e7-32572b77904f.png)

基本的にはローカル環境でもPEP8準拠用の各種フォーマッタとflake8の実行はコマンド1つでまとめて実行されるようにしているため、push前にフォーマッタの反映とチェックを行っています。どちらかというとフォーマッタの反映漏れなどをデプロイ前に検知するためにGitHub Actions上にてflake8のチェックを設けています。

ジョブのYAMLは以下のようになっています。

```yaml
  RunFlake8:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}'
      - name: Set the flake8 alias
        run: alias flake8=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/flake8
      - name: Run the flake8 command
        run: python ./scripts/run_flake8.py
```

まず`needs: [CreateCache, SetGlobalConstants]`の部分ですが、このneedsの指定は他のmypyやPyrightでのジョブと同じです。needsの指定が同じになったジョブに関してはGitHub Actions上でも同じタイミングでスタートし、実行が並列化されます（ジョブのノード上でも一緒のところに表示されます）。並列処理の記述が非常にシンプルで良いです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/dd8b312b-4769-ebf0-fc73-1861b9a57d55.png)

続いて以下の部分ですが、これはキャッシュしているPython環境の復元用の指定になっています。前述のインストール済みのPythonライブラリのキャッシュ関係の節で触れた（保存した）内容をジョブ環境ごとに復元するという処理になっています。各ジョブで使う箇所が多いため別ファイルに切り出して、そちらへと復元するPythonバージョンのみwithのpython-versionのパラメーターで指定するという形にしています。

```yaml
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}'
```

リポジトリ内で`.github/actions/setup_py_dependencies/action.yml`というパスで該当の処理を切り出したYAMLを配置しておけば、他のジョブ実行のYAMLから`uses: ./.github/actions/setup_py_dependencies`といったような指定をすれば該当の処理を呼び出すことができるようになります（`setup_py_dependencies`部分は切り出す処理に応じて任意の名前を設定してください）。

---

`setup_py_dependencies/action.yml`の中身は以下のようにしています。

```yaml
name: Setup the Python dependencies
description: This composite run steps require the dependencies cache before running.
inputs:
  python-version:
    description: Python version to use.
    required: true
runs:
  using: composite
  steps:
    - name: Set the Python version
      uses: actions/setup-python@v2
      with:
        python-version: ${{ inputs.python-version }}
    - name: Set the site package path to the environment variables
      run: python -c "import site; print(f'site-packages-path={site.getsitepackages()[0]}')" >> $GITHUB_ENV
      shell: bash
    - name: Restore the Python package cache
      uses: actions/cache@v2
      with:
        path: ${{ env.site-packages-path }}
        key: ${{ runner.os }}-pip-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
    - name: Restore the bin cache
      uses: actions/cache@v2
      with:
        path: /opt/hostedtoolcache/Python/${{ inputs.python-version }}/x64/bin/
        key: ${{ runner.os }}-bin-cache-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
```

以下のinputs部分ですが、これは外部から呼び出す際に渡すパラメーターの設定です。引数定義のようなものになります。今回はPythonバージョンを外部からもらう必要があるので設定しています。requiredはパラメーターを必須にするかどうかの設定となります。

```yaml
inputs:
  python-version:
    description: Python version to use.
    required: true
```

続いて以下のstepの箇所ですが、外部から指定されたPythonバージョンに応じてPython環境を設定しています。ここで設定した環境などは呼び出し元の外部のジョブにも反映されます（呼び出したstepに続く各stepでも設定した環境が利用できます）。指定されたパラメーターは`${{ inputs.パラメーター名 }}`という形でアクセスできます。

```yaml
    - name: Set the Python version
      uses: actions/setup-python@v2
      with:
        python-version: ${{ inputs.python-version }}
```

残りの以下の箇所はキャッシュの保存関係の節で触れた内容とほぼ似たような感じです。

Pythonのインストール済みのライブラリのパスとなる`site-packages`をPythonスクリプトを動かして取得し、GitHubが提供してくれている`uses: actions/cache@v2`を使いキャッシュのキーと復元先のパスを指定して各Pythonライブラリとコマンド実行用のbin関係をキャッシュから復元させています。

```yaml
    - name: Restore the Python package cache
      uses: actions/cache@v2
      with:
        path: ${{ env.site-packages-path }}
        key: ${{ runner.os }}-pip-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
    - name: Restore the bin cache
      uses: actions/cache@v2
      with:
        path: /opt/hostedtoolcache/Python/${{ inputs.python-version }}/x64/bin/
        key: ${{ runner.os }}-bin-cache-${{ env.site-packages-path }}-${{ hashFiles('**/requirements.txt') }}
```

---

元のflake8実行用のジョブに戻ります。Python環境が復元できたので次は以下のようにflake8のエイリアスを復帰させています。これはキャッシュからflake8やbinなどを復元しているものの、パスが通っていない（`flake8 ...`といったコマンドが打てない）ため設定しています。GitHub Actions特有のパスを指定しているのですが、今後パスが変わったりするのだろうか・・・？とも思いつつも今のところ整備してからはずっと変更が入っていないのと将来もし変わったらジョブが引っかかって検知できるでしょうから調整すれば良いか・・・と緩く考えています。

1年に1度くらいの調整くらいなら許容してしまおうと思っていますが、もし結構頻度高く変更が入るようであれば別のもっと良い方法を考えようと思います。

```yaml
      - name: Set the flake8 alias
        run: alias flake8=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/flake8
```

これでコマンド実行準備が整ったので以下のようにflake8実行用のコマンドを実行しています。Pythonスクリプトを挟んでいます。

```yaml
      - name: Run the flake8 command
        run: python ./scripts/run_flake8.py
```

呼び出しているPythonスクリプト（run_flake8.py）内では単純にflake8のコマンドを実行して標準出力があるかどうか（flake8ではエラーや警告が無ければ標準出力は空になります）のみチェックしています。以下のような感じのPythonのスクリプトになっています。

```py:run_flake8.py
import sys
from logging import Logger

sys.path.append('./')

import scripts.command_util as command_util
from apysc._console import loggers
from scripts.apply_lints_and_build_docs import FLAKE8_COMMAND

logger: Logger = loggers.get_info_logger()


def _main() -> None:
    """
    Run the flake8 command.

    Raises
    ------
    Exception
        If command standard out is not blank.
    """
    logger.info('flake8 command started.')
    stdout: str = command_util.run_command(command=FLAKE8_COMMAND)
    if stdout != '':
        raise Exception('There are flake8 errors or warning.')


if __name__ == '__main__':
    _main()
```

# mypyとPyrightによるチェック

こちらもflake8と同じような感じですがデプロイ前にmypyとPyrightによる型のチェックもGitHub Actions上で通しています。以下の赤枠部分となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/1821d1be-d09c-eb02-3716-70d0b6fb0187.png)

mypyはPython公式の型チェックライブラリ、Pyright（Pylance）はマイクロソフト製の型チェックライブラリ（とVS Codeなどの拡張機能）となっています。

ローカルで事前にチェックは行ってからpushしているためチェックを忘れていなければ基本的にはGitHub Actions上ではほぼ引っかかりません。

VS Codeのエディタ上のPylance（内部ではPyrightが動いています）の型チェックを通っていれば大体mypyも通るのですが、若干mypyとPyrightで引っかかる箇所の傾向が異なるので両方チェックするようにしています（並列化などしていると対して処理時間の面は気にならないため）。

Pyrightに関しては元々はnode.js経由で実行されているのでインストールなどがPythonプロジェクトではちょっと手間だったのですが、マイクロソフト公式ではありませんがpip経由だけでインストールできるPyrightのライブラリが出ているため今はPythonプロジェクトでのCI組み込みが大分シンプルになっています（node.jsなどをあまり意識することなく他のライブラリと同様にrequirements.txt完結（pip完結）で対応ができます）。

その辺のインストールなどは以下にて以前記事にしたので必要に応じてご確認ください。

https://qiita.com/simonritchie/items/045fd2373565f3e49036

mypyもPyrightもGitHub Actions上の記述はflake8とほぼ同じです。リポジトリのチェックアウト → PythonとPythonライブラリのキャッシュ環境からの復元 → コマンド用のエイリアス設定 → mypyやPyrightを動かして標準出力の内容をチェックするPythonスクリプトの実行 といった感じです。

```yaml:mypyのジョブ
  RunMypy:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the mypy alias
        run: alias mypy=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/mypy
      - name: Run the mypy command
        run: python ./scripts/run_mypy.py
```

```yaml:Pyrightのジョブ
  RunPyright:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the Pyright alias
        run: alias pyright=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/pyright
      - name: Run the Pyright command
        run: python ./scripts/run_pyright.py
```

Pythonスクリプト内部もflake8と同じような感じで、コマンドの実行と標準出力内に警告やエラーがあればraiseしてGitHub Actionsをエラーで止める（後続のデプロイなどのジョブを行わない）形にしています。

# numdoclintによるチェック

numdoclintは自作のLintライブラリなのですが、NumPyスタイルのdocstringが書かれていることをチェックするためのLintです。

※docstringやNumPyスタイルに関しては昔記事を書いているので必要に応じてご確認ください。

https://qiita.com/simonritchie/items/49e0813508cad4876b5a

関数やメソッドなどにdocstringが書かれていること、docstringのスタイルがNumPyスタイルになっていること、引数内容などとdocstringの内容を比較してずれていないことなどをチェックしてくれるようにしています。

※機能の詳細とかはライブラリ公開時に記事にしているためそちらも必要に応じてご確認ください。

https://qiita.com/simonritchie/items/84c4d4b2528309c30359

numdoclintを作る前から存在していたお仕事でのPythonプロジェクトでもこのライブラリを途中から入れたのですが、docstring関係の記事などをいくつか書いていながら割と記述漏れであったりコード側とのずれ（コード編集時にdocstring側の更新を忘れていたりなど）が発生していました。やはり人の目などでのチェックだけではなくLintに頼るのが漏れやミスが減って良いなと思いますし、GitHub Actions上でCI的に組み込まれていると安心感があります。

また、docstringを参照してマークダウン変換してGrammarlyチェックが出来るようにしたりSphinx連携したり・・・と色々やっているのですが、スタイルが（Lintが通って）統一されているのは変換時などの制御がシンプルで良いなと思いました。

作ってから3年くらい使い続けていますが今のところは快適で、切り落としたいとは感じていないため今後も使い続けていきそうな気がします。

GitHub Actions上では以下の赤枠部分が該当します。flake8やmypyなどと並列で実行されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/1345c993-d6a7-7c2f-1fdf-396c14ae3446.png)

YAML上では以下のようなジョブの記述になっています。こちらもflake8やmypy、Pyrightなどとほぼ同じ処理の内容になっています。

```yaml
  RunNumdoclint:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the numdoclint alias
        run: alias numdoclint=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/numdoclint
      - name: Run the numdoclint command
        run: python ./scripts/run_numdoclint.py
```

# 各Pythonバージョンでの単体テストの実行

GitHub Actions上で各Pythonバージョンで単体テストを流すようにしています。本記事を執筆している時点ではEoLになっていないPython3.7, 3.8, 3.9, 3.10の4バージョンを使っています。

今のところローカルの開発環境上ではPoetryなどを挟んで各Pythonバージョンを用意して・・・とはしておらず、サポートしている最低バージョンのPython + Dockerで作業しています。将来特定のPythonバージョンでテストが引っかかる・・・みたいなことが頻発した場合はPoetryなどをさらに挟もうか・・・くらいに考えていますが最低バージョンで作業していれば今のところほぼほぼ他のバージョンでもテストが通っているのでそのままになっています。

テストにはpytestを使っており、並列実行しておりCPUを100％使い切っているためローカルでも複数のPythonバージョン全てでテストをする・・・とすると一気にテスト時間が数倍になってしまうためそこまではやっていません（リードタイムが落ちて開発体験が悪くなるので）。

ただしデプロイ前には各PythonバージョンでチェックはしたいのでGitHub Actions上では各Pythonバージョンでテストを流しています。各Pythonバージョンごとに並列実行されるためGitHub Actions上のジョブも長くなったりは基本的にしないのは素敵です。

GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/26f54260-f4c0-833b-e346-736611267f3a.png)

ジョブのYAMLは以下のようになっています。

```yaml
  RunTestsOnEachPythonVersion:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [
          '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}',
        ]
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install playwright and Chromium
        run: playwright install chromium
      - name: Run the test runner command
        uses: nick-fields/retry@v2
        with:
          timeout_seconds: 300
          max_attempts: 3
          command: python ./scripts/run_tests.py
```

strategyのmatrix部分で各Pythonバージョンを指定しているのはキャッシュ生成のジョブなどと同様です。各Pythonバージョンで並列化して単体テストを流していくために指定しています。

以下の部分ですが、一部の単体テストでPlaywright（Seleniumのようなマイクロソフト製のブラウザ操作用のライブラリ）を使ったテストが存在し、そちらはpipなどとは別途制御が必要なのでインストールのコマンドを流しています。シンプルなコマンドではありますが他のジョブでも割と記述が重複しているのでそのうち別ファイルに切り出して記述を統一するかもしれません。Chromiumのインストールとなりますが、GitHub Actions上で数秒で処理が終わっているので今のところ特にここはキャッシュなど行っていません。

```yaml
      - name: Install playwright and Chromium
        run: playwright install chromium
```

pytestの実行箇所は他のflake8実行などと同じような感じになっています。ただし`retry@v2`のライブラリを使わせていただいています。これはエラーになったりタイムアウトなどした場合のリトライをシンプルに設定してくれます。`timeout_seconds`にはタイムアウトまでの秒数、`max_attempts`には最大リトライ回数を指定します。

これはpytestを各Python環境でさらに並列実行して処理時間を短縮しているのですが、その影響で一部ファイルシステムなどを使用している処理でバッティング等してフレーキーテストが稀に発生しているため、リトライする形にしています。

```yaml
      - name: Run the test runner command
        uses: nick-fields/retry@v2
        with:
          timeout_seconds: 300
          max_attempts: 3
          command: python ./scripts/run_tests.py
```

フレーキーテストに関しては「発生しないようにきっちり直すべきだ」という意見の方もいらっしゃると思いますが個人的には最近公開された以下の記事に共感できるところも多く、「テストを並列化してチェック時間を短くして開発体験を良くしたい」「フレーキーテストを厳密に対応するよりも新しい開発や開発体験の良さを優先したい」と判断してリトライで対応しています（この辺は賛否が分かれると思っていますが個人のプロジェクトなので自分の判断で良いかなと）。

※フレーキーテストに関しては最近公開された以下の記事が面白かったのでそちらも必要に応じてご確認ください。

https://www.publickey1.jp/blog/22/itjenkinsdevops_days_tokyo_2022.html

https://www.publickey1.jp/blog/22/itjenkinsdevops_days_tokyo_2022_1.html

# 単体テストのカバレッジの取得と保存

単体テストのカバレッジの計算もGitHub Actions上で行っています。カバレッジ計算は行うと結構テスト時間が長くなるため、ローカルでの実行は少々辛い・・・と感じており、GitHub Actions上でのみ実行するようにしています。GitHub Actions上では以下の部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/7a34f941-e8b6-3083-bc7d-ad72bcd5a71e.png)

こちらに関しては処理時間が長くなるのと1回だけ取得できれば良いので単一のPythonバージョン環境でのみ実行しています。

ジョブのYAMLとしては以下のようになっています。内容としては通常の各Pythonバージョンの単体テストと大体同じなのですが、末尾の方のカバレッジや通過したテスト数などをoutputsとしてジョブの結果の値に設定するための各処理のみ追加になっています。

このoutputsで設定した値は別のジョブで参照し、BYOBライブラリによるREADME上のバッジの値更新時に使用しています。outputsを設定せずにこのジョブ内でBYOBライブラリでバッジを更新すれば良いのでは？と最初思ってそのように組んでいたのですが、GitHub Actionsのジョブを複数並列して実行している点、BYOBライブラリのバッジ用のパラメーター設定が単一のファイルに設定される点から割と並列処理によってBYOBライブラリによる値の更新が競合して正確にバッジの値が更新されない・・・というケースに遭遇したため、「各ジョブでバッジ更新用のパラメーターをoutputsに設定する」 → 「バッジ更新専用のジョブで各outputsの値を参照して直列で更新処理を行う」という形にして並列処理を使っても競合しないようにしています。

```yaml
  RunTestsAndSaveCoverageAndPassingNum:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 25
    outputs:
      COVERAGE: ${{ steps.set-coverage.outputs.coverage }}
      PASSING_TESTS_NUM: ${{ steps.set-passing-tests-num.outputs.passing-tests-num }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the pytest alias
        run: alias pytest=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/pytest
      - name: Install playwright and Chromium
        run: playwright install chromium
      - name: Run the test runner command
        uses: nick-fields/retry@v2
        with:
          timeout_seconds: 240
          max_attempts: 5
          command: python ./scripts/run_tests_and_save_coverage_and_num.py
      - name: Set the environment variables from .env file
        uses: c-py/action-dotenv-to-setenv@v3
        with:
          env-file: .env
      - name: Set the coverage to outputs value
        id: set-coverage
        run: echo '::set-output name=coverage::${{ env.COVERAGE }}'
      - name: Set the passing tests number to outputs value
        id: set-passing-tests-num
        run: echo '::set-output name=passing-tests-num::${{ env.PASSING_TESTS_NUM }}'
```

まず以下の部分ですが、これは`.env`ファイルに設定された各値をGitHub Actions上に反映するためのアクションのライブラリとなります。Python上で.envファイルに対してカバレッジなどの値を設定しているため、その値をGitHub Actions上で参照できるようにするために使わせていただいています。

```yaml
      - name: Set the environment variables from .env file
        uses: c-py/action-dotenv-to-setenv@v3
        with:
          env-file: .env
```

Pythonスクリプトとしては以下のようになっています。やっていることとしては主に以下の3点となります。

- テスト用のpytestのコマンドの実行
- 標準出力を参照して結果のカバレッジ部分を`.env`ファイルに保存
- 同様に標準出力を参照して通過したテスト数を`.env`ファイルに保存

`.env`内へは`パラメーター名=値`の形式で書き込むことでGitHub Actions上で使えるようになります。

カバレッジの計算に関してはpytest-covというpytestのプラグインを使用しています。

https://github.com/pytest-dev/pytest-cov

できればテスト結果のカバレッジに関係した各値をJSONなどで参照できるとシンプルで良い・・・と思いましたが、READMEやドキュメントを軽く見てみてもその設定方法が分からず・・・という感じだったためとりあえず標準出力から正規表現で該当の値を抽出するということをしています。

```py:run_tests_and_save_coverage_and_num.py
import re
import sys
from logging import Logger
from typing import List
from typing import Match
from typing import Optional

sys.path.append('./')

from apysc._console import loggers

logger: Logger = loggers.get_info_logger()


def _main() -> None:
    """
    Run the testing command.

    Raises
    ------
    Exception
        If there are any failed tests.
    """
    import scripts.command_util as command_util
    from apysc._string import string_util
    logger.info('testing command started.')
    stdout: str = command_util.run_command(
        command=(
            'pytest --cov=./apysc tests/ -v -s --workers auto '
            '--cov-report term-missing'
        ))
    tail_stdout: str = string_util.get_tails_lines_str(
        string=stdout, n=10)
    if ' failed, ' in tail_stdout:
        raise Exception('There are failed tests.')
    _save_coverage(stdout=stdout)
    _save_passing_tests_num(stdout=stdout)


def _save_passing_tests_num(stdout: str) -> None:
    """
    Save a passing tests number to the `.env` file.

    Parameters
    ----------
    stdout : str
        Test command stdout.
    """
    lines: List[str] = stdout.splitlines()
    passing_test_num: str = ''
    for line in lines:
        if ' passed in ' not in line:
            continue
        match: Optional[Match] = re.search(
            pattern=r'.*? (\d+?) passed',
            string=line)
        if match is None:
            continue
        passing_test_num = match.group(1)
    logger.info(
        'Saving a passing tests number to the .env file : '
        f'{passing_test_num}')
    with open('.env', 'a') as f:
        f.write(f'PASSING_TESTS_NUM="{passing_test_num}"\n')


def _save_coverage(stdout: str) -> None:
    """
    Save a test coverage to the `.env` file.

    Parameters
    ----------
    stdout : str
        Test command stdout.
    """
    lines: List[str] = stdout.splitlines()
    coverage: str = ''
    for line in lines:
        if not line.startswith('TOTAL '):
            continue
        match: Optional[Match] = re.search(
            pattern=r'TOTAL\s+?(\d+?)\s+?(\d+?)\s',
            string=line)
        if match is None:
            raise Exception('Test coverage value is missing.')
        statements: int = int(match.group(1))
        missed: int = int(match.group(2))
        coverage_float: float = 100 - (missed / statements) * 100
        coverage = f'{coverage_float:.2f}%'
    logger.info(
        'Saving a coverage to the .env file: '
        f'{coverage}')
    with open('.env', 'a') as f:
        f.write(f'COVERAGE="{coverage}"\n')


if __name__ == '__main__':
    _main()
```

ジョブの末尾の以下の2つのstepではoutputs用のカバレッジと通過テスト数のパラメーターを設定しています。`::set-output name=<パラメーター名>::<パラメーター>`の形式でechoするとoutputsの箇所で値を参照できるようになります。

```yaml
      - name: Set the coverage to outputs value
        id: set-coverage
        run: echo '::set-output name=coverage::${{ env.COVERAGE }}'
      - name: Set the passing tests number to outputs value
        id: set-passing-tests-num
        run: echo '::set-output name=passing-tests-num::${{ env.PASSING_TESTS_NUM }}'
```

出力した値はoutputsセクションで`steps.<step名>.outputs.<パラメーター名>`という形式でGitHub Actions上からsteps内の値を参照できるので結果の値として設定しています。これで他のジョブからoutputsの値が参照できます。

```yaml
    outputs:
      COVERAGE: ${{ steps.set-coverage.outputs.coverage }}
      PASSING_TESTS_NUM: ${{ steps.set-passing-tests-num.outputs.passing-tests-num }}
```

余談ですが本記事執筆時点で単体テストの関数（メソッド）数が1438、カバレッジ99.75％となっているようです。個人的な趣味（技術的盆栽）のプロジェクトでたくさん書いたなぁ・・・としみじみと感じています（もちろん今後もたくさん書いていきますが・・・）。

# doctest

GitHub Actions上にてCIの一部としてdoctestを実行しています。doctest自体に関しては以前以下の記事でも書いたためそちらの記事も必要に応じてご確認ください。

https://qiita.com/simonritchie/items/124708cfb7a36b857d92

例えば以下はパッケージ内のPythonコードのとあるインターフェイスの一部なのですが、docstringのExamplesセクションにdoctest用の記述をしています。コード例として参照できるようにしつつもコード例がいつの間にか動かなくなっている・・・ということを避けるためにGitHub Actions上でデプロイ前にチェックされるようにしています。

```py
...
    def draw_rect(
            self, *,
            x: Union[int, Int],
            y: Union[int, Int],
            width: Union[int, Int],
            height: Union[int, Int]) -> Rectangle:
        """
        Draw a rectangle vector graphics.

        Parameters
        ----------
        x : Int or int
            X position to start drawing.
        y : Int or int
            Y position to start drawing.
        width : Int or int
            Rectangle width.
        height : Int or int
            Rectangle height.

        Returns
        -------
        rectangle : Rectangle
            Created rectangle.

        References
        ----------
        - Graphics draw_rect interface document
            - https://simon-ritchie.github.io/apysc/graphics_draw_rect.html  # noqa

        Examples
        --------
        >>> import apysc as ap
        >>> stage: ap.Stage = ap.Stage()
        >>> sprite: ap.Sprite = ap.Sprite()
        >>> sprite.graphics.begin_fill(color='#0af')
        >>> rectangle: ap.Rectangle = sprite.graphics.draw_rect(
        ...     x=50, y=50, width=50, height=50)
        >>> rectangle.x
        Int(50)
        >>> rectangle.width
        Int(50)
        >>> rectangle.fill_color
        String('#00aaff')
        """
        rectangle: Rectangle = Rectangle(
            parent=self, x=x, y=y, width=width, height=height)
        self.add_child(child=rectangle)
        return rectangle
...
```

GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/90fffc48-5074-294a-59b1-ccd96d9b3d69.png)

YAML上では該当のジョブは以下のようになっています。「doctest実行用のPythonスクリプトの実行」「通過したdoctest数を他のジョブのバッジ設定で使うためのoutputsへの設定」を行っている形で、他の単体テスト実行のジョブとほぼ構成は同じような感じになっています。

```yaml
  RunDocTest:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    outputs:
      PASSING_TESTS_NUM: ${{ steps.set-passing-tests-num.outputs.passing-tests-num }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the pytest alias
        run: alias pytest=/opt/hostedtoolcache/Python/${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}/x64/bin/pytest
      - name: Run the doctest runner command
        run: python ./scripts/run_doctest_and_save_passing_num.py
      - name: Set the environment variables from .env file
        uses: c-py/action-dotenv-to-setenv@v3
        with:
          env-file: .env
      - name: Set the passing tests number to outputs value
        id: set-passing-tests-num
        run: echo '::set-output name=passing-tests-num::${{ env.PASSING_TESTS_NUM }}'
```

実行しているdoctest実行用のPythonスクリプトは以下のようになっています。doctestはビルトインでも実行できますが、`--doctest-modules`引数を付与することでpytestでも実行できるのと、pytestの並列実行のプラグインなども便利に使っているためpytestを使用しています。

こちらに関してもpytestから結果の実行数などをファイルなどで取る方法が良く分からなかったため正規表現で標準出力から抽出 → `.env`ファイルに書き込んでGitHub Actions上から結果を参照できるようにしています。

```py
import re
import sys
from logging import Logger
from typing import List
from typing import Match
from typing import Optional

sys.path.append('./')

import scripts.command_util as command_util
from apysc._console import loggers

logger: Logger = loggers.get_info_logger()


def _main() -> None:
    """
    Run the testing command.

    Raises
    ------
    Exception
        If there are any failed tests.
    """
    logger.info("doctest command started.")
    stdout: str = command_util.run_command(
        command=(
            'pytest ./apysc/ --doctest-modules --workers auto -v -s'
        ))
    if ' failed, ' in stdout or 'Traceback' in stdout:
        raise Exception('There are failed tests.')
    _save_passing_tests_num(stdout=stdout)


def _save_passing_tests_num(stdout: str) -> None:
    """
    Save a passing tests number to the `.env` file.

    Parameters
    ----------
    stdout : str
        Test command stdout.
    """
    passing_test_num: str = _get_passing_test_num_from_stdout(
        stdout=stdout)
    logger.info('Saving a doctest passing tests number to the .env file.')
    with open('.env', 'a') as f:
        f.write(f'PASSING_TESTS_NUM="{passing_test_num}"\n')


def _get_passing_test_num_from_stdout(stdout: str) -> str:
    """
    Get a passing tests number from the stdout.

    Parameters
    ----------
    stdout : str
        Test command stdout.

    Returns
    -------
    passing_test_num : str
        Retrieved passing tests number.
    """
    lines: List[str] = stdout.splitlines()
    passing_test_num: str = ''
    for line in lines:
        if ' passed in ' not in line:
            continue
        match: Optional[Match] = re.search(
            pattern=r'.*? (\d+?) passed',
            string=line)
        if match is None:
            continue
        passing_test_num = match.group(1)
    return passing_test_num


if __name__ == '__main__':
    _main()
```

# ドキュメントに対するE2Eテスト

ドキュメントページ内のコードブロックは実行できる箇所はドキュメントビルド時などに実行されるようになっています。

本ライブラリ（apysc）がPythonでフロントエンドを書く（HTMLやjsなどのコードを出力する）ライブラリとなっているため、ドキュメントのコードブロックでもHTMLなどが生成されます。それらの出力結果はドキュメント内のコードブロックの直後などに表示されるようになっており、マウスイベントやアニメーションなどもドキュメント上で動くようにしています（ユーザー操作なども受け付けるようにし、結果がドキュメント上で分かりやすくするため）。

また、apyscライブラリではassert関係のインターフェイスも設けてあります（assert_equalなどのインターフェイスでjsのアサーションのコードを出力する機能があります）。

そのためドキュメント上の出力されたjsのコードなどで場合によってはエラーになっている・・・ということが発生しうる形になっています。ドキュメントの初回執筆時は問題がなくともライブラリ側のコードを日々どんどんアップデートしていっているのでいつの間にかドキュメント側のjsがエラーになっている・・・ということが発生しかねません。

ドキュメントページも現時点で150ページくらいになってきているのでアップデートの度に全体的に手動でチェックする・・・というのはリードタイムが悪くなるのでやりたくやりません。

そこでドキュメントに関しても各ページに対してE2EのテストをGitHub Actionsを使って行っています（ローカルでも実行できるようにはしています）。（書いたり保守するのが大変なので）細かいテストまでは行っていないのですが、ページを開いて実行されるjsやアサーションでエラーになっていない・・・という点は全ページ確認するようにしています（これだけでも大分安心感が得られました）。

GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/6c82a20d-0fda-8c88-729f-a2872145d069.png)

E2EのテストにはSeleniumではなくマイクロソフト製のPlaywrightライブラリを使用しています。

https://github.com/microsoft/playwright

最初は以前から使っていて慣れていたSeleniumを使っていたのですが謎のエラー（エラーの内実とメッセージが合っていないエラー）に遭遇し、しばらく粘っていたものの解決が難しそう・・・だったため、試しに以前話題になっていたPlaywrightを使ってみたところ大分開発体験が良く感じたのでPlaywrightに移行してしまいました。

https://qiita.com/mainy/items/3a9de19f440991f67f34

E2Eテストはpytestと連携できるようで、pytestで使われている・・・方が多そうな印象なのですが、今のところはPlaywright + GitHub Actionsだけで動かしています。今のところは不満とかは無いのですがもしかしたらそのうちpytestを絡める形に変更するかもしれません。

Playwrightのインストールですが、PyPI登録がされているためにpipでインストールができます。そのため他のPythonライブラリなどと同様にrequirements.txtに書いてGitHub Actions上でキャッシュすることができます。

また、別途ブラウザのインストール指定が必要になるのですがこれはpipではなく通常のコマンドで実行する必要があります。Chromiumであれば`playwright install chromium`といったコマンドが必要になります。GitHub Actions上であればpip関係の処理を通した後に`run: playwright install chromium`といったような指定が必要になります。たったこれだけの記述だけでGitHub Actions上でヘッドレスのChromiumが動かせるようになります。Seleniumでのエラーに色々悩まされていたのは何だったのか・・・というレベルのシンプルさです。

また、現状ではChromiumのインストール結果はキャッシュしたりはしていないのですがGitHub Actions上で数秒程度でインストールが終わっているようなので特に気にせず使っています。

E2EテストのGitHub Actions上の記述は以下のようになっています。特に特筆すべき新しい記述はほとんど無い感じの内容になっています（strategy.matrixの指定で英語版と日本語版の各ドキュメントを指定しているくらいでしょうか）。大体の処理は呼び出しているPython上で操作しています。

```yaml
  RunDocE2ETest:
    needs: [CreateCache, SetGlobalConstants]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        lang: ['en', 'jp']
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Install playwright and Chromium
        run: playwright install chromium
      - name: Run the E2E test runner command
        run: python ./scripts/run_docs_e2e_tests.py --lang ${{ matrix.lang }}
```

Pythonコードは少々長いのでGitHub上のコードのリンクだけ貼っておきます。

https://github.com/simon-ritchie/apysc/blob/main/scripts/run_docs_e2e_tests.py

https://github.com/simon-ritchie/apysc/blob/main/apysc/_testing/e2e_testing_helper.py

大体快適に使えたのですが、使っていて少し気になった点としては以下の2点です（両方ともコード調整で対応できています）。

- 最初コードの都合で1ページごとにブラウザを起動するようにしていたら後半でリソースエラーとなってブラウザが起動しなくなる・・・というケースに遭遇しました。withステートメントとセットで使う形だったので特に気にせずに使っても内部で諸々破棄してくれるのかな？と思ったらそうでも無かった？のか使い方を間違えているのか・・・という感じです。この辺は処理時間の短縮にもなるため起動したブラウザを使いまわす形にして対応しています。
- Python上でonメソッドでイベント設定ができ、エラー関係のイベントも設定できるのですがフレーキーテスト気味というかテストのあるjsでもエラーが出たり出なかったり・・・に遭遇しました。この辺はとりあえず複数回チェックするといった制御で対応しています。ドキュメントの各ページのテストはGitHub Pages上のドキュメントページへリクエストを投げるのではなくリポジトリをcloneしてからそれらのローカルファイルに対してヘッドレスでチェックする・・・としているのでひとまずはこのように複数回チェックを流す感じで良いかと判断しています。pytestとか絡めるとこの辺はもっとシンプルになるのでしょうか・・・？将来その辺は試してみるかもしれません。

# ドキュメントのコードブロックの実行

ローカルでのドキュメントビルド時に基本的にセットでコードブロックも実行・同期されるのですが、念のためGitHub Actions上でもドキュメントのコードブロックを再実行してエラー無く通ることをチェックするようにしています。GitHub Actions上では以下の赤枠部分が該当します。結構並列実行数が多くなってきたため、確かGitHub Actionsの同時実行数が20くらい？だった気がするため同時実行数を分散させるためにタイミングを少し遅らせています（各Pythonバージョンでの単体テストのジョブ完了後に処理をスタートしています）。この辺の調整もneedsの指定を1行調整するだけなので楽で良いですね・・・。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/c3ebb472-e5f0-7343-696c-b6975039b218.png)

ジョブのYAMLは以下のようになっています。

```yaml
  CheckDocsCodeBlockError:
    needs: [CreateCache, SetGlobalConstants, RunTestsOnEachPythonVersion]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        alphabets-group: [
          'a',
          'bcdef',
          'g',
          'hijklmnopqrstuvwxyz',
        ]
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Run the checking command
        run: python ./scripts/check_docs_code_block_error.py --alphabets_group ${{ matrix.alphabets-group }}

```

こちらも他のジョブと比べて特に目新しい点は無く・・・という感じですが、強いて上げるなら結構数が多いのでstrategy.matrixの設定で対象ファイル名の先頭のアルファベットごとにグループを分割して並列実行する形にしています（アニメーション関係とかでaから始まるファイル名が多かったり・・・となっているのでaとかは単体のグループになっています）。

呼び出しているPythonスクリプトは以下のようになっています（少し長いのでリンクのみ貼っておきます）。

https://github.com/simon-ritchie/apysc/blob/main/scripts/check_docs_code_block_error.py

ドキュメントビルドの際に更新されたドキュメントのコードブロック部分を抽出＆実行などをしていたのでその辺の処理を参照しています。

# テスト用のプロジェクトに対する変換処理の実行と結果のHTMLに対するE2Eテスト

以前からPythonからHTMLやjsへと変換している都合、Python側は単体テストで色々やっているもののそれだけだとjs側などが動作確認できないため都度機能ごとなどにテスト用のプロジェクトを追加してPythonを動かして結果のHTMLなどを確認・・・としていました。また、この中にはjs環境で実行されるassertionなども色々と追加していました。

このあたりもコード編集時に再実行したりと使いまわしていたためリポジトリに残したまま運用していました。一方で機能が増える度にこの辺のテストプロジェクトも増えていったため手動での実行が結構微妙になってきました。

そこでGitHub Actions上で一通りのテストプロジェクトのPythonを実行し、結果のHTMLに対して簡易のE2EテストとしてPlaywrightを動かしてjsのエラーや失敗しているassertionが発生していないか・・・をチェックするようにしました。GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/592d7267-19f3-f704-a280-68deeb31e7b6.png)

整備した直後は一部古いプロジェクトなどでエラーが発生しており修正を入れたのですが、継続的な実行はやはり必要だな・・・と感じました。

ジョブのYAMLは他とほぼ同じ感じで先頭の文字のアルファベットごとにグループ化してstrategy.matrixに設定しているのと処理用のPythonスクリプトを呼び出す形になっています。

```yaml
  RunTestProjectsE2ETesting:
    needs: [CreateCache, SetGlobalConstants, RunTestsOnEachPythonVersion]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        alphabets-group: [
          'a',
          'bcdefghijklmno',
          'pqrs',
          'tuvwxyz',
        ]
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Install playwright and Chromium
        run: playwright install chromium
      - name: Run the E2E testing command
        run: python ./scripts/run_test_projects_e2e_testing.py --alphabets_group ${{ matrix.alphabets-group }}
```

# docstringのReferencesに記載されているドキュメントの有無確認

この記事を書いている終盤で追加になったのですが、docstringのReferencesセクションに記載されているドキュメントへのリンクが404にならないようにドキュメントの有無をチェックする処理をGitHub Actionsに追加しました（※執筆の終盤タイミングに追加となったので他の節のスクショやコードなどが少し古くなっています）。

開発のエディタにはVS Codeを使っているのですが、VS Codeの場合インターフェイス（関数やクラス・メソッドなど）にマウスオーバーした際のdocstring表示で、docstring内にURLなどがあるとそれらがリンクに変換されて表示されます。

これを利用してdocstringのReferencesセクションには詳細ドキュメントのURLを記述して使っています（便利）。

https://twitter.com/simonritchie_sd/status/1416721676155248641?s=20&t=enb4Ma3loOum_KmQP7pYvg

※ツイートのスクショは古い感じになっていますが、現在URLでbitlyなどの利用は廃止しています（ファイルの有無の確認などの面でも不便なため）。

ただし、ドキュメントファイル名を変更などするとこれらのリンク先が無くなって404になる可能性があります。それらの更新漏れを検知するためにGitHub Actionsのジョブでデプロイ前にチェックするようにしました。

GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/b12cfa3d-2df3-a021-7d72-2837128532da.png)

ジョブのYAML部分はシンプルな内容になっています。ほぼPython環境の準備とPythonスクリプトの呼び出しのみです。

```yaml
  CheckReferencesDocumentsExist:
    needs: [CreateCache, SetGlobalConstants, RunTestsOnEachPythonVersion]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Run the checking command
        run: python ./scripts/check_docstring_references_docs_exist.py
```

Pythonスクリプトとしては以下のリンク先のようになっています。

https://github.com/simon-ritchie/apysc/blob/main/scripts/check_docstring_references_docs_exist.py

詳細は省きますが、やっていることとしては以下のような感じになっています。

- プロジェクトパッケージ内の各モジュールのパスのリストを取得
- 各モジュールを読み込んで内部のdocstringのReferencesセクションの内容を取得
- References内のURLでドメインがドキュメント関係＆拡張子がドキュメントページとしての`.html`になっている場合にファイル名部分を抽出
- 抽出されたファイル名のドキュメントがリポジトリ内に存在することをチェック

※あまり変わらないのかもしれませんが、処理時間（開発体験的になるべくジョブの時間を短くしたい）と負荷をかけてしまうことを加味してドキュメントページへリクエストを投げたりはせずにローカルファイルの有無でチェックしています。

# バッジの更新

諸々のテストやLintなどが通ったらREADMEのバッジの更新処理を行っています。各ジョブでoutputsとして設定した結果の値（テストカバレッジなど）を参照して、バッジ更新用の[BYOBライブラリ](https://github.com/RubbaBoy/BYOB)を使って直列で更新を行っています。他の節でも触れましたが、更新箇所が各ジョブに分散していて並列実行されるようになっているとBYOBによる更新処理がバッティングする時があるのでこのジョブでまとめて直列で更新処理を流していっています。

GitHub Actions上では以下の赤枠部分となります。大分終盤の位置のジョブになってきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/f89eb21e-40c9-ff3d-d996-1fc3a62c3399.png)

ジョブの記述は少し長めですがやっていることはシンプルにoutputsなどのパラメーターを参照して各バッジの値を更新しているだけです。

```yaml
  UpdateReadmeBadgesToPassingStatus:
    needs: [
      RunFlake8,
      RunMypy,
      RunNumdoclint,
      RunPyright,
      RunCheckingApyscTopLevelImporting,
      UpdateReadmeBadgesToCheckingStatus,
      RunTestsAndSaveCoverageAndPassingNum,
      CheckDocsCodeBlockError,
      RunTestProjectsE2ETesting,
      RunDocTest,
      RunDocE2ETest,
      SetGlobalConstants,
    ]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Update each lint badge status
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: '${{ needs.SetGlobalConstants.outputs.PASSING_LINTS_BADGE_NAME }}'
          LABEL: '${{ needs.SetGlobalConstants.outputs.PASSING_LINTS_BADGE_LABEL }}'
          STATUS: 'flake8 | mypy | Pyright | numdoclint'
          COLOR: ${{ needs.SetGlobalConstants.outputs.PASSING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Echo the passing unit tests number value
        run: echo ${{ needs.RunTestsAndSaveCoverageAndPassingNum.outputs.COVERAGE }}
      - name: Update tests line coverage
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: unit_tests_coverage
          LABEL: 'unit tests coverage'
          STATUS: ${{ needs.RunTestsAndSaveCoverageAndPassingNum.outputs.COVERAGE }}
          COLOR: ${{ needs.SetGlobalConstants.outputs.PASSING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Echo the passing unit tests number value
        run: echo ${{ needs.RunTestsAndSaveCoverageAndPassingNum.outputs.PASSING_TESTS_NUM }}
      - name: Update the passing unit tests number
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: passing_unit_tests_num
          LABEL: 'passing unit tests number'
          STATUS: ${{ needs.RunTestsAndSaveCoverageAndPassingNum.outputs.PASSING_TESTS_NUM }}
          COLOR: ${{ needs.SetGlobalConstants.outputs.PASSING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Update the passsing doctests number
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: passing_doctests_num
          LABEL: 'passing doctests number'
          STATUS: ${{ needs.RunDocTest.outputs.PASSING_TESTS_NUM }}
          COLOR: ${{ needs.SetGlobalConstants.outputs.PASSING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Update the passing unit tests Python versions status
        uses: RubbaBoy/BYOB@v1.2.1
        with:
          NAME: '${{ needs.SetGlobalConstants.outputs.PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_NAME }}'
          LABEL: '${{ needs.SetGlobalConstants.outputs.PASSING_UNIT_TEST_PYTHON_VERSIONS_BADGE_LABEL }}'
          STATUS: '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }} | ${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }} | ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }} | ${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}'
          COLOR: ${{ needs.SetGlobalConstants.outputs.PASSING_BADGE_COLOR }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

# PyPIへのアップロード

諸々のチェックが終わった時点でPyPIへのアップロードを行っています。やっていることとしてはwheelライブラリなどを使ったビルド処理と`pypa/gh-action-pypi-publish`のActionsのライブラリを使ったPyPIへのパッケージのアップロードとなります。GitHub Actions上では以下の赤枠部分となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/82094c4f-756a-154a-6bd8-5a186440f02b.png)

ジョブのYAMLは以下のようになっています。

```yaml
  DeployToPyPI:
    needs: [
      RunFlake8,
      RunMypy,
      RunNumdoclint,
      RunPyright,
      RunCheckingApyscTopLevelImporting,
      RunTestsAndSaveCoverageAndPassingNum,
      CheckDocsCodeBlockError,
      RunTestProjectsE2ETesting,
      RunDocTest,
      RunDocE2ETest,
      SetGlobalConstants,
      UpdateReadmeBadgesToCheckingStatus,
    ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Execute the Python package build
        run: python ./scripts/build.py
      - name: Upload to PyPI
        uses: pypa/gh-action-pypi-publish@master
        with:
          password: ${{ secrets.PYPI_TOKEN }}
```

ビルド用のPythonスクリプトに関してはリンクを貼っておきます。やっていることはシンプルにwheelライブラリなどを使ったビルドのコマンドの実行などを制御しています。

https://github.com/simon-ritchie/apysc/blob/main/scripts/build.py

以下の部分でビルド結果をPyPIへのアップロードしています。ビルドが終わっていれば[`pypa/gh-action-pypi-publish`のライブラリ](https://github.com/pypa/gh-action-pypi-publish)のおかげでPyPIアカウントのトークンを指定するだけで非常な記述で済んでいます。

```yaml
      - name: Upload to PyPI
        uses: pypa/gh-action-pypi-publish@master
        with:
          password: ${{ secrets.PYPI_TOKEN }}
```

現在PyPIでは2段階認証などを設定しているとトークン経由じゃないと怒られるようになっているため、トークン設定などは対応しておく必要があります。その辺は以前記事にしたので必要に応じてご確認ください。

https://qiita.com/simonritchie/items/9cfea12087c1f4b597a7

# Releaseの追加

特にRelease内に更新内容を載せる・・・というところまではできていないのですが、デプロイしたタイミングで対象のバージョンを取得してReleaseの作成をするようにしています。GitHub Actions上では以下の赤枠部分が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/98681804-50e7-41a0-cac1-c8339e00ea6f.png)

ジョブのYAMLは以下のようになっています。大まかなやっていることとしてはPythonスクリプトを動かしてライブラリのバージョンを取得 → Releaseの作成を[`actions/create-release`のActionsのライブラリ](https://github.com/actions/create-release)を使用して実行・・・といった具合です。

```yaml
  CreateReleaseNotes:
    needs: [
      RunFlake8,
      RunMypy,
      RunNumdoclint,
      RunPyright,
      RunCheckingApyscTopLevelImporting,
      RunTestsAndSaveCoverageAndPassingNum,
      CheckDocsCodeBlockError,
      RunTestProjectsE2ETesting,
      RunDocTest,
      RunDocE2ETest,
      SetGlobalConstants,
      UpdateReadmeBadgesToCheckingStatus,
      DeployToPyPI,
    ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}
      - name: Set the apysc version to environment variable
        run: python -c "import apysc; print(f'apysc_version={apysc.__version__}')" >> $GITHUB_ENV
      - name: Create the release note
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: v${{ env.apysc_version }}
          release_name: v${{ env.apysc_version }}
          draft: false
          prerelease: false
```

ライブラリバージョンは他のPythonライブラリのように`<ライブラリ名>.__version__`という属性で取れるようにしているため、`run: python -c "import apysc; print(f'apysc_version={apysc.__version__}')" >> $GITHUB_ENV`という記述部分でapyscパッケージのimportと__version__属性の参照を行い、結果の値をGitHub Actions上で参照できるように環境変数（`$GITHUB_ENV`）に設定されるようにしています。

後は`actions/create-release`ライブラリを使ってバージョンやリリース名などを渡す形でReleaseの作成をしています。

```yaml
      - name: Create the release note
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: v${{ env.apysc_version }}
          release_name: v${{ env.apysc_version }}
          draft: false
          prerelease: false
```

GitHub上ではこんな感じで作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/4f6faa12-d305-e1ff-b915-46e9c50cb804.png)

これだけでもとりあえずコード参照・各バージョン時点での差分比較などの面では便利ではありますが、将来的には更新内容も反映したいところではあります。現在は主な更新内容に関してはGitHub DiscussionsのAnnouncementなどに載せる形で別途手動対応しているのですが、プルリクを経由したい場合にその辺を自動で作ってくれる新機能も以前見かけたのでその辺を活用して何とか自動化できないかなーと思っています（今後の課題です）。

https://zenn.dev/hankei6km/articles/github-release-note-generator-to-various-purposes

# PyPIへアップロードされたパッケージのインストールと最低限の処理の実行確認

念のためPyPIへアップロードしたパッケージのインストールが通ることや、最低限の処理を動かすといった制御を最後に行っています。というのもPyPIへは全コードは含めない（例えば単体テストのコードなどは含めていません）ため、通常の単体テストなどは通っていたもののPyPIへアップロードしたパッケージではエラーになって動かない・・・というケースが発生する可能性も0ではないためです。

GitHub Actions上では以下の赤枠部分が該当します。各Pythonバージョンで並列して実行しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/56ffac60-e11c-fee0-c5af-4d3283628663.png)

ジョブのYAMLの記述は以下のようになっています。

```yaml
  RunPackageInstallingTests:
    needs: [DeployToPyPI, SetGlobalConstants]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [
          '${{ needs.SetGlobalConstants.outputs.PYTHON_37_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_38_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_39_VERSION }}',
          '${{ needs.SetGlobalConstants.outputs.PYTHON_310_VERSION }}',
        ]
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup the Python dependencies
        uses: ./.github/actions/setup_py_dependencies
        with:
          python-version: ${{ matrix.python-version }}
      - name: Run the package installing and testing script
        run: python ./scripts/install_released_package.py
      - name: Checkout
        uses: actions/checkout@v2
```

Checkoutの処理を行っているのは、デプロイされたライブラリバージョンの取得を行うためです（のmainブランチに反映されている最新のコードの`apysc.__version__`の値からライブラリのバージョンを取得しています）。

ただしCheckout分が残っているとテストとしては良くないので呼び出しているPythonスクリプト上で各ディレクトリの削除を行っています。

Pythonスクリプトは以下のようになっています。

```py
import os
import shutil
import sys
import time
from typing import List

sys.path.append('./')

import apysc as ap
import scripts.command_util as command_util

_PIP_COMMAND: str = f'pip install apysc=={ap.__version__}'
_APYSC_TEST_CODE: str = (
    'import apysc as ap;'
    'stage: ap.Stage = ap.Stage();'
    "ap.save_overall_html(dest_dir_path='./');"
)


def _main() -> None:
    """
    Install the released (latest) apysc package from PyPI.
    Also, removing checked out directories and additional minimum tests
    will be run.
    """
    os.system('pip freeze | xargs pip uninstall -y')

    count: int = 0
    while True:
        stdout: str = command_util.run_command(command=_PIP_COMMAND)
        if 'ERROR: Could not find a version' not in stdout:
            break

        if count >= 10:
            raise Exception('apysc package installing is failed.')
        count += 1
        print(f'Sleeping 10 seconds... (retrying count: {count})')
        time.sleep(10)

    file_or_dir_names: List[str] = os.listdir('./')
    for file_or_dir_name in file_or_dir_names:
        if os.path.isfile(file_or_dir_name):
            os.remove(file_or_dir_name)
            continue
        if os.path.isdir(file_or_dir_name):
            shutil.rmtree(file_or_dir_name, ignore_errors=True)

    os.system(f'python -c "{_APYSC_TEST_CODE}"')
    assert os.path.exists('./index.html')
    assert os.path.exists('./jquery.min.js')
    print('HTML file is created by the apysc package correctly.')


if __name__ == '__main__':
    _main()
```

やっていることとしては以下のような処理となります。

- 一旦pipでインストールされている各ライブラリのアンインストール（Python環境のキャッシュからの復帰時点で色々インストールされているため）
  - ※ただし今振り返るとこれはキャッシュから復帰させないで単純にPython環境の指定だけすれば良いのでは？という気もしてきています・・・。将来詳細を再確認して直すかもしれません。
- デプロイされたパッケージのインストール
  - `pip install apysc=={ap.__version__}`といった具合にデプロイしたライブラリバージョンを指定する形でインストールのコマンドを指定しています。
  - ただしデプロイ後インストールできるようになるまで若干のラグが発生します。それまではエラーになったりするため、以下のようにインストールが通るまで一定時間のスリープとリトライを設定しています。

```py
    count: int = 0
    while True:
        stdout: str = command_util.run_command(command=_PIP_COMMAND)
        if 'ERROR: Could not find a version' not in stdout:
            break

        if count >= 10:
            raise Exception('apysc package installing is failed.')
        count += 1
        print(f'Sleeping 10 seconds... (retrying count: {count})')
        time.sleep(10)
```

- Checkoutされたリポジトリ内容の削除
- 最低限のライブラリを使ったコードを実行してみて、（PythonからHTMLなどの出力を行うライブラリなので）結果のHTMLなどが出力されていることを確認

# CodeQL

コードの脆弱性チェックなどのためにCodeQLも有効化しています。GitHubのSecurityタブ部分を開いてCode scanning alertsの部分をUIの内容に合わせてぽちぽちしていけばGitHub Actionsで有効にできるのでお手軽で素晴らしいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/a819cb93-c7a0-eed8-7f97-5aa2a28e250f.png)

結構たくさんコードを書いてきた気はしますが、Closed: 3となっていることから分かると通り1回だけ軽微な内容で引っかかりました（ドキュメントソースで引っかかり、そのソースがSphinxで英語と日本語の各ドキュメントに渡されるので3と表示されていますが同じコード部分の1か所が引っかかっています）。

引っかかった場合には「どこが引っかかったのか」「どんな点が引っかかったのか」「どのように修正すれば良いのか」などがGitHubのSecurityタブに表示されたり数字がUI上に表示されて警告が出ていることが分かったりするようになっています。自分1人で作業しており、セキュリティの専門家がプロジェクトに居るわけでもないので大変助かりますしUXも良いと感じています。

# secretlint

以下の記事で知ったのですが、secretlintというものをGitHub Actionsで設定してシークレットキーを誤って公開してしまったりをやらかした場合にすぐに検知できるようにしています。今のところ引っかかったことが無いので引っかかるとどういった感じなのかは分かっていません（まあ引っかからないに越したことは無いため・・・）。

https://zenn.dev/matken/articles/5-github-actions-for-running-start

対象のワークフローのYAMLは以下のようになっています。

```yaml
name: Secretlint

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  secretlint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3.1.1
        with:
          node-version: 17
      - name: Install
        run: npm install
      - name: Lint with Secretlint
        run: npx secretlint "**/*"
```

また、リポジトリ直下にpackage.jsonを配置しないと動いてくれなかったように記憶しています。package.jsonは以下のようにしています。

```json
{
  "scripts": {
    "secretlint": "secretlint '**/*'"
  },
  "devDependencies": {
    "@secretlint/secretlint-rule-preset-recommend": "5.2.0",
    "secretlint": "5.2.0"
  }
}
```

# その他今後検討していること

## GitHub Actionsのローカル実行

今のところテスト用のリポジトリを作っているのでGitHub Actionsのジョブ編集時に試行錯誤がたくさん必要な場合はそちらのGitHub Actionsを利用、そうでないなら直接pushしてGitHub Actionsの動作確認・・・としています。

ただしローカル実行も出来る？気配が他の記事を拝見していてあるので将来もしかしたらその辺も整備するかもしれません（ローカルでのYAML編集がやりやすくなるように）。

https://zenn.dev/snowcait/articles/2b4a903b9fd584

# 終わりに: GitHub Actionsを使い続けてみての所感

他のCI/CD関係だと仕事でのJenkinsくらいしか触ったことが無いのであまり他の多くのCI/CDのものと比較はできないのですが、GitHub Actionsはしばらく触っていた感じ大分気に入っています。主に以下の辺が良いなと思っています。

- 学習コストが低めに感じています。インストールやインスタンスを立てたりなども不要でシンプルですし、最初にデプロイ関係を整備していった時には触ったことの無い状態から1日で資料を読んで整備が終わった（その後色々ジョブを都度追加していっていますが・・・）のはすぐに使えて良いなと思いました。
- マネージドなのでやはり保守がとても楽です。調子が悪くなってもGitHubの中の方が修正・対応してくれるのは楽で良いです。
- publicリポジトリであれば無料での利用でも処理時間などの制限も気になるものは無いのがとても助かっています。並列実行などでの処理時間短縮なども気兼ねなく設定できますし、プライベートで色々試して勉強する・・・といった時に無料で色々試せるのはとても助かっています。
- 各ジョブのワークフローをグラフ的に可視化してくれるのも割と便利だなと思っています。YAML上だと分かりづらいケースでもぱっと見で処理の流れやジョブ間の依存状態、並列設定具合などが確認できるのはUXが良いなと思います。
- GitHubでユーザーが多いだけあってライブラリが豊富で、やりたいことはライブラリを探せば大体公開されているというのはYAML上の記述がシンプルになって素晴らしいです。
- プラグインやライブラリのインストールをインスタンス上などで指定する・・・という形ではなくライブラリの指定が特定のジョブ内で指定する形なのは良いなと思いました。ジョブごとにライブラリの指定が疎結合な感じになって保守が楽に思います。特定のライブラリバージョンを変更すると全ジョブや各YAML全体に影響が出たり、アップデート時などに依存関係に悩まされたりも少なくなりそうな気がしています。

# 参考文献・サイト

https://github.com/microsoft/playwright

https://github.com/nick-fields/retry

https://www.publickey1.jp/blog/22/itjenkinsdevops_days_tokyo_2022.html

https://www.publickey1.jp/blog/22/itjenkinsdevops_days_tokyo_2022_1.html

https://github.com/pytest-dev/pytest-cov

https://qiita.com/mainy/items/3a9de19f440991f67f34

https://github.com/RubbaBoy/BYOB

https://github.com/pypa/gh-action-pypi-publish

https://github.com/actions/create-release

https://zenn.dev/hankei6km/articles/github-release-note-generator-to-various-purposes

https://zenn.dev/matken/articles/5-github-actions-for-running-start

https://zenn.dev/snowcait/articles/2b4a903b9fd584