Python パッケージのバージョン管理一元化と自動リリースプロセスを整理する
自分は Python OSS をメンテしていますが、パッケージングに関して二つの課題があります。
- バージョン管理一元化
- 自動リリース
なんとなく頭にある知識を整理してこれらを解決したいと思います。
課題
バージョン管理一元化
Python パッケージを管理する際何も意識をしないとおおよそバージョン番号が 三重管理 になってしまいます。
-
pyproject.toml
のメタデータproject.version
pyproject.toml[project] version = "0.1.0"
-
__init__.py
ファイルなどの__version__
変数__init__.py__version__ = "0.1.0"
- Git のタグ名
$ git tag v0.1.0
リリース作業の際にパッケージのバージョンを Bump するのですが、この三か所を逐一変更する作業はとても面倒だし人間なのでミスるかもしれないです。 理想はどれか一つを変更したら動的に他の二つも反映されて欲しいです。
自動リリースプロセス
「自動リリースを構成すること」自体は GitHub Actions を使えば簡単です。 しかし「何をトリガに」「どのようなプロセスで」自動リリースするのが正しいのかは考える余地があります。 「トリガ」の選定は上記の「バージョン管理一元化」で採用する情報源の選定結果によっても変化するでしょう。
考えられるトリガは以下の通りです。
- GitHub Release の発行
- 👉 Web UI からの作業がトリガになる
- 👉 タグも Web UI 操作で同時に発行される
- 👉 タグ名が情報源になる
- Git タグの Push
- 👉 CLI からの
git tag v0.1.0; git push origin v0.1.0
がトリガになる - 👉 これもタグ名が情報源になる
- 👉 GitHub Release は GitHub Actions からの
gh release create
コマンドで自動生成する
- 👉 CLI からの
- メタデータの変更
- 👉
pyproject.toml
or__version__
変数が情報源になる - 👉 情報源ファイルの変更を含んだ Pull request のマージがトリガになる
- 👉 GitHub Actions でバージョン変更検知するロジックが若干面倒そう? (
HEAD^
のチェックアウトが必要かもしれない) - 👉 これも GitHub Release は自動生成する
- 👉
バージョン管理一元化
解決手段を網羅して、最後にどの手段が良さそうか検討します。
pyproject.toml
pyproject.toml
のメタデータ project.version
を一次情報として、他を動的に設定するケースを考えます。
-
__version__
変数-
importlib.metadata
のversion()
で動的にメタデータのバージョンを読み込める
__init__.pyfrom importlib.metadata import version __version__ = version(__package__)
-
- Git タグ
- GitHub Actions でパッケージメタデータ or
__version__
のバージョン変更を検知したら、GitHub Release を通してf"v{version}"
タグを発行する
- GitHub Actions でパッケージメタデータ or
__version__ 変数
__version__
変数 を一次情報として、他を動的に設定するケースを考えます。
- パッケージメタデータ
- ビルドバックエンド
hatch
(hatchling
) などであれば標準のproject.dynamic
とツール側のメタデータを構成することで動的にバージョンメタデータを生成できる
pyproject.toml[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] dynamic = ["version"] [tool.hatch.version] path = "src/my_package/__init__.py"
- ビルドバックエンド
- Git タグ
- (
pyproject.toml
版に同じ)
- (
Git タグ
Git タグを一次情報として、他を動的に設定するケースを考えます。
- パッケージメタデータ
-
hatch
のプラグインhatch-vcs
を構成することで Git タグからバージョンメタデータを動的に設定できる- 例えばタグ
v0.1.0
ならバージョン0.1.0
を振ってくれる - さらにタグ
v0.1.0
からコミットが進むと0.1.1.dev0+gf8acd72.d20240418
のように開発バージョンを自動で振ってくれる
- 例えばタグ
pyproject.toml[build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] dynamic = ["version"] [tool.hatch.version] source = "vcs"
-
-
__version__
変数- (
pyproject.toml
版に同じ)
- (
参考資料
__version__
変数 👉 メタデータ
Git タグ 👉 メタデータ
メタデータ 👉 __version__
変数 (importlib.metadata
)
結論
hatch-vsc
による Git タグでの一元化がよさそう に思いました。
良さそうなポイント:
- 👍 自然なデータフロー
- Git タグ 👉 (
hatch-vcs
) 👉 メタデータ 👉 (importlib.metadata
) 👉__version__
変数
- Git タグ 👉 (
- 👍 「Git タグの発行」がこの後の Publish 用のイベントとしても自然なフローになりそう
- 👍
hatch-vcs
が開発バージョンも自動採番してくれる
ハマるかもしれないなポイント:
-
hatch-vcs
における Editable インストール時に自動採番が不正確になる のでハックが必要になる
対照的に:
- (メタデータ or
__version__
変数) 👉 Git タグ
こちら側の手法はエコシステムがそこまで豊富ではなさそう? 🤔
自動リリースプロセス
上記結論からして、(GitHub Release の発行) or (Git タグの Push) をイベントとしてリリースフローをディスパッチするのがよさそうです。 どのようなリリースプロセスが最良か検討してみます。
GitHub Release
- 🕹️ GitHub Release の Web UI からリリースを発行する
- 👉 同時にタグが発行される
- 👉 同時にリリース発行イベントによって リリース用 GitHub Actions がディスパッチする
- 👉
github.ref
はその新しいタグになっている
- 👉
- 👉 リリース用 GitHub Actions がパッケージをビルドする
- 👉
hatch-vcs
がタグを参照してそのバージョンでビルドしてくれる
- 👉
- 👉 リリース用 GitHub Actions がパッケージを Publish する
パッと見良さそうです。 Web UI から発行する (= not CLI) リリースプロセスというのは「GitHub Pull request によるマージプロセス」と似たような「承認してやったぜという仕事感」があって良いです。 まぁ gh
CLI からでもいいんですが。
出来ないこと:
- GitHub Release の発行と同時にビルド成果物をアップロードする
自分のユースケース Pure Python なのであまり関係ないのですが、拡張モジュール系の場合問題が発生しそうです。
Web UI または gh
CLI から手作業でビルド成果物をアップロードするのは難しいので、リリースのみを発行することになります。 GitHub Actions であとからリリースに対して成果物をビルド・アップロードするにしても、その時間差が発生して「Latest リリースの成果物が存在せずダウンロードできない」という事象が発生しそうです。 具体的なケースとしては uv や ruff や rye などの「シェルを介してインストールする系」ですね。 インストールする際は GitHub Release のコンテンツからバイナリをダウンロードする仕組みのはずなので、ダウンロードできない一定期間が発生すると思われます。
これに対するアイディアとしては:
- そもそも Pure Python プロジェクトであれば GitHub Release に成果物はアップロードしない
- 実際、著名な Pure Python プロジェクトな FastAPI とか HTTPX もアップロードしてない
- 多分ユーザーもリリースからのインストールは使わないし、GitHub インストールするにしても Release からじゃなくて
pip install git+https://...
のようにブランチ or タグを利用すると思う
- GitHub Actions 手動ディスパッチでリリースする
-
workflow_dispatch
で UI からバージョンを指定してディスパッチする - GitHub Actions がビルドする
- GitHub Actions が Release を発行する
-
自分のユースケースは前者で全然良さそうですが、好奇心から後者も詰めてみます。
... あとよく考えたら「GitHub Release」が「ユーザーへのリリース通知」と前提をおくと、前者だと何も準備完了してないのにリリース通知が送信されます。 これも Pure Python ならそこまでそこまで問題ないものの、やはり後者で GitHub Release 以外の全てのリリースプロセスが終わってから GitHub Release を発行する というのが「最も正しい」フローだと考えられます。
GitHub Actions ディスパッチ
これが恐らく「ぼくのかんがえた最強のリリースプロセス」になる気がします。
- 🕹️
workflow_dispatch
で UI からバージョンを指定してディスパッチする-
workflow_dispatch
によるインプットは自由入力値になる- GitHub Release Web UI での分かりやすいタグバリデーションが利用できないのが少し残念
-
hatch version
コマンドとかのバージョン自動インクリメントを応用できないかな?- デフォルト
hatch
はhatch version minor
で自動インクリメント (ファイル直接編集) できるけど、hatch-vcs
プラグインを使っていると Git タグ発行になるので流石に実装されてなかった 。hatch
は高度に Pluggable な設計になっているので、何か適切にインポートして Planning だけできないかな 🤔
- デフォルト
-
- 🤖 (Job:
build
) ビルドして成果物をアーティファクトにアップロードする - 🤖 (Job:
github-release-draft
) Draft 版 GitHub Release を作成する - 🤖 (Job:
github-release-review
) レビュー必須な環境を使用する- ここでは Draft 版 GitHub Release を目でチェックして承認することができる
- 承認されたら、タグ作成して origin にプッシュする
- 出来れば否認されたときに Draft Release を削除したい
- 🤖 (Job:
pypi
) PyPI にデプロイする - 🤖 (Job:
github-release-publish
) GitHub Release の Draft 状態を解除して発行する
workflow_dispatch
は Web UI から実行できるのが良いです。 またはローカル作業が必要になりますが workflow_dispatch
でなくても Git タグ Push をトリガ 🕹️ として扱っても同等の事ができそうです。
TestPyPI も含めた戦略
hatch-vcs
によって開発バージョンを自動採番してくれるので、メインブランチマージをトリガに開発バージョンを TestPyPI に Publish することで、パイプラインの健全性を確かめるのも良いです。
その場合のワークフローの戦略を考えます。
- Build
- Any Push 毎
- Publish TestPyPI
- Default branch Push 毎
- Plan Release (Draft Release 👉 Publish PyPI 👉 Publish Release)
workflow_dispatch
- or Git タグ Push
まとめ
GitHub Release 駆動
やっぱりこれが一番楽そうです。 Pure Python でモダンな著名 OSS でもそうしてるし。
アイディアをまとめた「YAML っぽいイメージ図」です。 これは正しい GitHub Actions ワークフローファイルでは全くをもってありませんしテストもしてません。
on: push
jobs:
lint:
test:
build:
publish-to-testpypi:
# (デフォルトブランチ Push) or (タグ Push)
if: (github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) or (startsWith(github.ref, 'refs/tags/'))
needs:
- lint
- test
- build
publish-to-pypi:
# タグ Push
if: startsWith(github.ref, 'refs/tags/')
needs:
- publish-to-testpypi
CI (on: push
) と Publish (on: release
) で 2 つのワークフローファイル分ける戦略も多いです。 本番 PyPI だけならそのやり方で簡潔に収まるものの、 TestPyPI にを含めると冗長的になります。 両方のワークフローファイルに同様の Build 👉 Publish to TestPyPI のステップを追加しなくてはいけません。 なのでワークフローを 1 つにして、全体を on: push
で Publish ジョブを if:
で絞るのが簡潔になって良いかなと思います。
ホントはこうしたい
(ポエム)
オープンソースなのであれば、誰でも自由にソフトウェアを変更できるべきです。 今のところそれは実現しており、世界の人々は GitHub では Pull request を出しています。 メンテナによるレビュープロセスを通して変更がマージされます。
そしてソースコードの変更だけでなくて、リリースについても同じことが適用できたらいいなと思いました。
例えば小規模なバグ修正をマージされた後、メンテはそれにあまり興味がなくリリースする気がなかったとしても、ユーザーがリリースを強く要求してくることがあるかもしれません。 メンテナがそれを呑む場合、メンテナは「自発的に」リリースプロセスを実行しなくてはいけません。 ほぼ自動化されているとは言え、何かのトリガを引くのはメンテナの自発的行動です。 ユーザーから提出された Small bugfix な Pull request であれば「受動的に」承認してマージすればいいだけなのに対して、要求されたリリースを実行するのはそこそこ心理的負荷になるのではないでしょうか。
リリースもユーザーが Pull request 経由で提出できて、メンテナは「承認の意思」されあればマージボタンを押してリリースを実行できる仕組みが簡単に構築できれば世界は平和になるのにな、と思いました。
少なくとも今回の調査では、ファイル変更 👉 Git タグ自動作成 👉 メタデータやバージョン変数に反映 👉 さらにコミットが進むと開発バージョン動的変更、という仕組みは現実的ではなさそうです。
astral-sh/uv
がほぼ理想的な (上に書いた Pull request 駆動を除いた) リリースプロセスを実現していることを発見しました。 これは確認したツールがやっていることの表です。
ツール | 駆動 | インプット | アウトプット |
---|---|---|---|
zanieb/rooster |
CLI | Unreleased な Pull requests (たぶん) |
CHANGELOG.md の更新 |
astral-sh/uv release.yml |
workflow_dispatch |
タグ名 | ワークフローをディスパッチする |
axodotdev/cargo-dist |
astral-sh/uv release.yml host ジョブ |
CHANGELOG.md |
変更履歴の最新部分を抽出したテキスト |
ncipollo/release-action |
astral-sh/uv release.yml announce ジョブ |
ビルドアーティファクト、抽出した変更履歴 | GitHub Release |
-
zanieb/rooster
を CLI で実行して Unreleased な Pull request をまとめてCHANGELOG.md
を更新して Push する。 Zanie 氏 の個人ツールっぽい。ruff
でも使っているようです。 -
astral-sh/uv
のrelease.yml
をworkflow_dispatch
からタグを指定して実行する
-
release.yml
のhost
ジョブにおいて、axodotdev/cargo-dist
によってCHANGELOG.md
の最新部分が抽出される
release.yml
の announce
ジョブにおいて、ビルドアーティファクトと抽出された CHANGELOG.md
の最新部分をインプットに ncipollo/release-action
アクションで GitHub Release が発行される