uvとgithub actionsによる効率的なpythonパッケージのバージョン更新

# 概要
[uvで作成したpythonパッケージのバージョン更新をgithub actionsから実施するワークフローサンプル](https://github.com/jiroshimaya/version-update-workflow-sample)を作りました。主な機能は以下の通りです。

- lint、format、testの実行
- Gitタグを用いたバージョン更新
- PyPIおよびTestPyPIへの公開


# モチベーション

uvで作成したpythonパッケージのバージョン管理を一元化し、GitHubとPyPIの状態を自然に同期させるために、本サンプルを作りました。

通常、uvを使ってPythonパッケージを作成する際のバージョンアップ手順は以下の通りです：
- `pyproject.toml` の `project.version` を手動で更新
- `uv build && uv publish` を実行（バージョンは `pyproject.toml` に基づいて決定）

GitHubでソースコードを管理する場合、さらに以下の作業が必要です：
- 更新内容をGitHubに反映
- 最新バージョンに対応するGitタグを追加

これらの手順には以下の課題があります：
- バージョンがGitのタグと `pyproject.toml` で二重管理される
- プルリクエスト時に `pyproject.toml` の更新を忘れることがある。忘れると、バージョン更新のためだけのコミットやプルリクエストが必要になる
- GitHubへのプッシュとPyPIへの公開が別々に行われるため、GitHubとPyPIを同じ状態に保つには注意が必要

これらの課題を解決するために、以下を目指しました：
- バージョン情報をGitタグで一元管理
- GitHub Actionsでバージョン更新、ビルド、公開を同時に実行

これにより、`pyproject.toml` とGitタグのバージョンの二重管理を避けることができます。また、GitHub Actionsを通じてPyPIを更新することで、GitHubとPyPIの状態を意識せずに一致させることができ、開発の効率化につながると考えました。

# 利用ツール

- **パッケージ管理**: uv
- **Lintツール**: ruff, mypy
- **フォーマットツール**: ruff
- **テストツール**: pytest, bats, act
- **タスク管理**: taskipy
- **ビルドツール**: hatchling, hatch-vcs

# 使い方

## GitHub Actionsでの実行

### 手順概要
1. `.github`ディレクトリとその中身をGitHubリポジトリのデフォルトブランチにアップロードします。
2. GitHubのSecretsに`TEST_PYPI_TOKEN`と`PYPI_TOKEN`を登録します。
2. GitHub > Setting > Actions > General > Workflow Permissionで`read and write permissions`を選択
3. GitHub > Actionsから該当のworkflowを自動実行します。

### python-check.yaml
- `.py`ファイルに対して、LintとFormatを実施します。
- `main`ブランチへのプルリクエストをトリガーとします。

### publish-to-testpypi.yaml
- バージョン更新とTestPyPIへの公開を行います。
- GitHub Actionsから手動で実行します。
- 実行時に以下のオプションを指定できます（すべて任意）:
  - **バージョン番号**: `v`で始まるセマンティックバージョニング形式の番号。空欄の場合、`v`で始まる最新のタグが使用されます。
  - **Recreate Tag**: チェックすると、指定されたバージョン番号が既に存在する場合、タグを作り直します。
  - **Dry Run**: チェックすると、リモートへのタグのプッシュやTestPyPIへの公開など、Workflow外に影響を残す処理を行いません。

### publish-to-pypi.yaml
このファイルは、バージョンを更新し、PyPIにパッケージを公開するためのものです。  
実行方法や指定可能なオプションは、`publish-to-testpypi.yaml`と同じです。

## ローカルでの実行
### 環境構築
以下はM1 macOSでの手順です。他のOSを使用している場合は、適宜読み替えてください。

#### [uv](https://github.com/astral-sh/uv)
```sh
# uvのインストール
curl -LsSf https://astral.sh/uv/install.sh | sh
```

#### dockerまたは[docker desktop](https://www.docker.com/ja-jp/products/docker-desktop/)
actを実行するために必要です。商用利用する際は、利用規約に注意してください。

#### [act](https://github.com/nektos/act)
このツールは、ローカル環境でGitHub Actionsを実行するために使用します。  
Dockerコンテナ内でアクションを実行し、コンテナのサイズは3種類から選べます。ここではMediumサイズを選択します。

``` 
brew install act
act --container-architecture linux/amd64
# コンテナのサイズを聞かれたらMediumを選択
```

#### [bats](https://github.com/bats-core/bats-core)
bashのテストツールです。

```
# batsのインストール。ローカルでシェルのテストをしたい場合のみ必要
brew install bats
```

### 単体実行

#### テスト

uv経由でpytestを実行できます。

```sh
uv run task test
```

#### update_version.sh
workflowでのバージョン更新に用いるシェルスクリプトです。

```sh
sh .github/scripts/update_version.sh [-v version] [-i increment_type] [-n] [-d]
```

詳細は以下を御覧ください。
https://gist.github.com/jiroshimaya/5f4524ca296357e1c5347f1674217529

### workflow

actでworkflowのテストを実行できます。

```sh
act [trigger] -j [jobname] -W [workflow yaml filepath] -e [event file path]
```

#### テスト

トリガーまたはjobを指定して実行します。
```sh
# triggerとしてpull_requestを指定
act pull_request -W .github/workflows/python-check.yaml
# jobを指定
act -j test -W .github/workflows/python-check.yaml
```

#### publish
原則として、dry-runで実行してください。テスト中に実際にpublishされると、管理が複雑になるためです。
workflow_dispatchがトリガーとなるジョブの場合は、eventファイルで必要な入力を指定します。

```json:tests/workflow/event.json
{"inputs": {"version": "", "recreate": "true", "dry_run": "true"}}
```

```sh
act -j publish -W .github/workflows/publish-to-testpypi.yaml -e tests/workflow/event.json
```

複数のeventに対してテストしたい場合はスクリプトを使用してください。

```sh
uv run task test-workflow # batsで実行
uv run task test-workflow-py # pytestで実行
```

# 検討メモ
上記の形に落ち着くまでに検討したことのメモです。

## バージョン管理の方法
バージョンをgit tagとpyproject.tomlのどちらで管理するかを検討しました。pyproject.tomlで管理する場合、sedコマンドで動的に更新し、git tagはその情報に基づいて作成されます。しかし、pyproject.tomlの書き換えによりコミット履歴が増えることが懸念されたため、git tagでの管理を選びました。

## publishワークフローの設定
バージョンを手動で入力する代わりに、現在のタグに基づいて自動でインクリメントする方法も考えましたが、試しに使ったところ、次のタグが何になるかわからないことにストレスを感じたため、手動入力にしました。GitHub ActionsのUIから現在のタグは簡単に確認でき、バージョンアップの頻度も低いため、手動での記載が問題にならないと判断しました。また、rc（リリース候補）などの特殊なタグにも対応できる利点があります。

TestPyPIとPyPIに異なるタイミングでpublishする必要があるため、バージョンを空欄にして最新タグでのpublishを可能にしました。TestPyPIでのバグ修正後、同じバージョンをPyPIに上げやすくするため、recreateフラグで現在のタグを最新コミットに付け直すこともできます。

## ビルドツールの選択
tagの作成やリモートへのpushはシェルスクリプトで行っていますが、自動インクリメントのためにhatchやbump-my-versionの使用も検討しました。しかし、uvとの相性問題のためか、エラーが発生し、解決に時間がかかりそうだったため、採用を見送りました。

## ビルドバージョン決定ツールの選定
gitのtagに基づいてバージョンを決定するツールは複数ありますが、uvのデフォルトビルドバックエンドがhatchlingであるため、相性の良さそうなhatch-vcsを使用することにしました。

## ワークフローの独立性
バージョンアップデートを独立したワークフローにすることも考えましたが、バージョンアップデートとpublishが2ステップになることが実際に試してみると煩わしかったため、独立させないことにしました。

## GitHub Actionsのローカルテスト

GitHub Actionsのローカルテストには、`pytest`と`bats`のどちらを使うべきか検討中です。以下にそれぞれの利点と欠点をまとめます。

### pytest
- **利点**: 追加のパッケージが不要で、Pythonでテストケースを記述できるため、Pythonに慣れている人には使いやすいです。
- **欠点**: シェルスクリプトのテストにPythonを使うのは、少し非効率に感じることがあります。また、実行に時間がかかるため、通常のpyファイルとは別に実行できるようにする必要があります。そのため、マーカーの管理が必要になります。

### bats
- **利点**: シェルスクリプトのテストには自然な選択肢です。
- **欠点**: 構文に慣れる必要があり、batsのインストールが必要です。`uv`でのインストールが可能であれば手間は少ないですが、手元ではエラーが発生しました（パッケージ自体は存在しているようです）。

## プルリクのマージによるタグの自動更新
mainブランチへのプルリクがマージされたときにtagを自動更新したほうが便利な可能性もあると思いました。ただ、minor、majorバージョンのインクリメントをどう運用するか悩みそうだったのと、複数のPRをたばねたアップデートを作りたいときもあるかと思い、採用しませんでした。ただ、PRのマージとパッチバージョンの更新を厳密に対応させる運用もありだと思いますし、必要に応じてやっても良いとは思います。

# 参考資料
- [Python パッケージのバージョン管理一元化と自動リリースプロセスを整理する](https://zenn.dev/mtkn1/scraps/b18816422f5b3f)
  - モチベーションが近く、広い視点から検討されており参考になりました。
- [uvだけでPythonプロジェクトを管理する](https://zenn.dev/turing_motors/articles/594fbef42a36ee)
  - uvの基本的な使い方の参考にしました。
- [ローカルで GitHub Actions が実行できる act のお作法を整理する](https://zenn.dev/simpleform_blog/articles/2b030b5f9e842c)
  - actのインストールや使い方の参考にしました。
- [actを使ってGitHub Actionsをローカル実行する](https://zenn.dev/yumemi_inc/articles/203779a4eba922)
  - actのインストールや使い方の参考にしました。
- [シェルスクリプトのテストフレームワーク Bats を試してみた](https://kakakakakku.hatenablog.com/entry/2018/05/14/000633)
  - batsのインストールや使い方の参考にしました。