【GitHub Actions】自作Pythonパッケージを自動ビルドしてPyPIとGitHubリリースまで一気にデプロイする

自作のライブラリをリリースするときに、ローカルでビルドして、TestPyPIにアップロードして確認して、PyPIにアップロードして、最後にタグ切ってリリースして・・・
面倒なのでGitHub Actionsでデプロイできるようにしました。

## この記事で目指すこと

リモートにタグをpushした際に、自動でPyPIにパッケージをアップロードし、GitHubにリリースを作成する。

ローカルからtagをpushすると、、、
```
git tag vX.X.X
git push origin vX.X.X
```

以下順番で自動的にデプロイされる
1. testPyPIへのアップロード
2. PyPIへのアップロード
3. GitHub上にリリースを追加しソースコードを配布

下記画像のようなワークフローを作成します。

![Screenshot 2024-08-09 at 17.18.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1622041/55ba543a-d487-7349-eebe-e3d13a783f54.png)

## 手順

### 準備
:::note warn
注意
パッケージングツールの設定ファイルとして setup.py ではなく、pyproject.toml を使用します。[PEP-518](https://peps.python.org/pep-0518/)により、モダンなPythonプロジェクトではpyproject.tomlの使用が推奨されています。
:::

Pythonのパッケージを用意してPyPIとTestPyPIのプロジェクトとしてアップロードします。
すでに自作のライブラリがPyPI上にある場合は飛ばしてください。

下記のチュートリアルに従って、サンプルパッケージをTestPyPIとPyPIにアップロードします。
https://packaging.python.org/ja/latest/tutorials/packaging-projects/#creating-a-license

### GitHub Actionsでワークフローファイルの作成
リポジトリ> Actions> set up a workflow yourself> 任意の名前でyamlファイル(今回はmain.yaml)を作成しcommit。空ファイルでOKです。

### PyPIでPublishingの設定
従来、GitHub ActionsからPyPIにアップロードをするためにはPyPIでAPIトークンを作成し、GitHub側でそのトークンをSecretとして保持する必要がありました。

しかしPublishingを使用することで、事前に設定してある特定のサービス(および特定のユーザー、リポジトリなど)との接続の際に、一時的なトークンを発行、認証までを自動で行うことができます！

設定の簡単ですし、トークンの有効期限が短いのでセキュリティ的にもPublishingを使用する方が良さそうです。

PyPIのプロジェクトページからPublishing> Add a new publisherを選択します。
https://pypi.org/manage/project/[プロジェクト名]/settings/publishing/

以下画像のように記入し、Addします。

![Screenshot 2024-08-09 at 17.54.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1622041/0ec3d2b6-af81-cc74-7e42-8cde967521f3.png)
2-4324-bfef-0471c4c39260.png)

TestPyPIを開き、同じようにPublisherを作成します。
Environment nameはPyPIとは別のものにします。

![Screenshot 2024-08-09 at 18.00.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1622041/aa62bdd2-57a2-9dd3-35c9-30bdef24ce63.png)

### GitHub Actionsでワークフローの設定
先ほど作成したyamlにワークフローを書いていきます。

#### 1. タグがpushされたときにワークフローを開始する

```yaml
name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI

on:
  push:
    tags:
      - 'v*.*.*'
```

#### 2. ビルド
ビルドするためにはpyproject.tomlがリポジトリ上に存在する必要がありますので.gitignoreに含めないでください。

```yaml
jobs:
  build:
    name: Build distribution 📦
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4 # コードを取得
    - name: Set up Python
      uses: actions/setup-python@v5 # Python環境のセットアップ
      with:
        python-version: "3.x"
    - name: Install pypa/build # ビルドツールをインストール
      run: >-
        python3 -m
        pip install
        build
        --user
    - name: Build a binary wheel and a source tarball
      run: python3 -m build # パッケージをビルド
    - name: Store the distribution packages # ビルド成果物をpython-package-distributionsという名前でdistディレクトリに一時的に保存
      uses: actions/upload-artifact@v4
      with:
        name: python-package-distributions
        path: dist/
```

#### 3. TestPyPIへの公開

```yaml
  publish-to-testpypi:
    name: Publish Python 🐍 distribution 📦 to TestPyPI
    needs:
    - build # ビルドジョブが完了した場合ジョブを開始する
    runs-on: ubuntu-latest

    environment:
      name: testpypi # Publisherに設定したenvironment nameを入力
      url: https://test.pypi.org/p/example-package-hanaosan0318 # プロジェクトURL

    permissions:
      id-token: write  # Publishingの権限を付与

    steps:
    - name: Download all the dists # 先ほど保存したビルド成果物をダウンロード
      uses: actions/download-artifact@v4
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution 📦 to TestPyPI # TestPyPIへ公開
      uses: pypa/gh-action-pypi-publish@release/v1
      with:
        repository-url: https://test.pypi.org/legacy/
```

#### 4. PyPIへの公開

```yaml
  publish-to-pypi:
    name: >-
      Publish Python 🐍 distribution 📦 to PyPI
    needs:
    - publish-to-testpypi # TestPyPIへの公開が完了した場合ジョブを開始する
    runs-on: ubuntu-latest
    environment:
      name: pypi # Publisherに設定したenvironment nameを入力
      url: https://pypi.org/p/example-package-hanaosan0318 # プロジェクトURL
    permissions:
      id-token: write

    steps:
    - name: Download all the dists
      uses: actions/download-artifact@v4
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution 📦 to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1
```

#### 5. リリースの作成

```yaml
  github-release:
    name: >-
      Create GitHub Release with source code
    needs:
    - publish-to-pypi # PyPIへの公開が完了した場合ジョブを開始
    runs-on: ubuntu-latest
  
    permissions:
      contents: write # GitHubリリースを作成するための権限
  
    steps:
    - name: Checkout code
      uses: actions/checkout@v4 # コードを取得
  
    - name: Create GitHub Release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ワークフローが実行されるたびに自動的に生成される一時的なトークン
      run: >-
        gh release create
        '${{ github.ref_name }}'
        --repo '${{ github.repository }}'
        --notes "Release for version ${{ github.ref_name }}"
```

これでワークフローを定義することができました！

yamlファイルの全体は以下リンクから見られます。

https://github.com/hanaosan/ci-cd-practice-python-library/blob/main/.github/workflows/main.yml

## デプロイしてみる

pyproject.tomlのversionを今回リリースバージョンに修正してpushしておきます。
```pyproject.toml
[project]
name = "example_package_hanaosan0318"
version = "2.0.3" # リリースバージョンに変更
authors = [
  { name="Example Author", email="author@example.com" },
]
```

タグを作成しpushします。
```
git tag v2.0.3
git push origin v2.0.3
```

PyPIとTestPyPIにパッケージがアップロードされています。

![Screenshot 2024-08-09 at 18.43.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1622041/a6319019-361d-45be-fcb1-0a42b1c1e71e.png)

またリリースが作成され、ソースコードがzipとtar.gzで配布されていることが確認できるはずです。

![Screenshot 2024-08-09 at 18.44.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1622041/60c9245d-ddab-bb46-ae3b-53a790d6320e.png)


これでリモートにタグをpushした際に、自動でPyPIにパッケージをアップロードし、GitHubにリリースを作成することができました。


### 参考

- 今回使ったリポジトリ: https://github.com/hanaosan/ci-cd-practice-python-library
- [GitHub Actions CI/CD ワークフローを用いてパッケージ配布物のリリースを公開する](https://packaging.python.org/ja/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#workflow-definition)