Closed7

Python パッケージのバージョン管理一元化と自動リリースプロセスを整理する

まちゅけんまちゅけん

自分は Python OSS をメンテしていますが、パッケージングに関して二つの課題があります。

  1. バージョン管理一元化
  2. 自動リリース

なんとなく頭にある知識を整理してこれらを解決したいと思います。

課題

バージョン管理一元化

Python パッケージを管理する際何も意識をしないとおおよそバージョン番号が 三重管理 になってしまいます。

  1. pyproject.toml のメタデータ project.version
    pyproject.toml
    [project]
    version = "0.1.0"
    
  2. __init__.py ファイルなどの __version__ 変数
    __init__.py
    __version__ = "0.1.0"
    
  3. Git のタグ名
    $ git tag v0.1.0
    

リリース作業の際にパッケージのバージョンを Bump するのですが、この三か所を逐一変更する作業はとても面倒だし人間なのでミスるかもしれないです。 理想はどれか一つを変更したら動的に他の二つも反映されて欲しいです。

自動リリースプロセス

「自動リリースを構成すること」自体は GitHub Actions を使えば簡単です。 しかし「何をトリガに」「どのようなプロセスで」自動リリースするのが正しいのかは考える余地があります。 「トリガ」の選定は上記の「バージョン管理一元化」で採用する情報源の選定結果によっても変化するでしょう。

考えられるトリガは以下の通りです。

  1. GitHub Release の発行
    • 👉 Web UI からの作業がトリガになる
    • 👉 タグも Web UI 操作で同時に発行される
    • 👉 タグ名が情報源になる
  2. Git タグの Push
    • 👉 CLI からの git tag v0.1.0; git push origin v0.1.0 がトリガになる
    • 👉 これもタグ名が情報源になる
    • 👉 GitHub Release は GitHub Actions からの gh release create コマンドで自動生成する
  3. メタデータの変更
    • 👉 pyproject.toml or __version__ 変数が情報源になる
    • 👉 情報源ファイルの変更を含んだ Pull request のマージがトリガになる
    • 👉 GitHub Actions でバージョン変更検知するロジックが若干面倒そう? (HEAD^ のチェックアウトが必要かもしれない)
    • 👉 これも GitHub Release は自動生成する
まちゅけんまちゅけん

バージョン管理一元化

解決手段を網羅して、最後にどの手段が良さそうか検討します。

pyproject.toml

pyproject.toml のメタデータ project.version を一次情報として、他を動的に設定するケースを考えます。

  • __version__ 変数
    • importlib.metadataversion() で動的にメタデータのバージョンを読み込める
    __init__.py
    from importlib.metadata import version
    
      __version__ = version(__package__)
    
  • Git タグ
    • GitHub Actions でパッケージメタデータ or __version__ のバージョン変更を検知したら、GitHub Release を通して f"v{version}" タグを発行する

__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__ 変数 👉 メタデータ

https://hatch.pypa.io/1.9/config/metadata/#version

Git タグ 👉 メタデータ

https://github.com/ofek/hatch-vcs

メタデータ 👉 __version__ 変数 (importlib.metadata)

https://packaging.python.org/ja/latest/guides/single-sourcing-package-version/#:~:text=importlib.metadata

結論

hatch-vsc による Git タグでの一元化がよさそう に思いました。

良さそうなポイント:

  • 👍 自然なデータフロー
    • Git タグ 👉 (hatch-vcs) 👉 メタデータ 👉 (importlib.metadata) 👉 __version__ 変数
  • 👍 「Git タグの発行」がこの後の Publish 用のイベントとしても自然なフローになりそう
  • 👍 hatch-vcs が開発バージョンも自動採番してくれる

ハマるかもしれないなポイント:

対照的に:

  • (メタデータ 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 のコンテンツからバイナリをダウンロードする仕組みのはずなので、ダウンロードできない一定期間が発生すると思われます。

これに対するアイディアとしては:

  1. そもそも Pure Python プロジェクトであれば GitHub Release に成果物はアップロードしない
    • 実際、著名な Pure Python プロジェクトな FastAPI とか HTTPX もアップロードしてない
    • 多分ユーザーもリリースからのインストールは使わないし、GitHub インストールするにしても Release からじゃなくて pip install git+https://... のようにブランチ or タグを利用すると思う
  2. GitHub Actions 手動ディスパッチでリリースする
    1. workflow_dispatch で UI からバージョンを指定してディスパッチする
    2. GitHub Actions がビルドする
    3. GitHub Actions が Release を発行する

自分のユースケースは前者で全然良さそうですが、好奇心から後者も詰めてみます。

... あとよく考えたら「GitHub Release」が「ユーザーへのリリース通知」と前提をおくと、前者だと何も準備完了してないのにリリース通知が送信されます。 これも Pure Python ならそこまでそこまで問題ないものの、やはり後者で GitHub Release 以外の全てのリリースプロセスが終わってから GitHub Release を発行する というのが「最も正しい」フローだと考えられます。

GitHub Actions ディスパッチ

これが恐らく「ぼくのかんがえた最強のリリースプロセス」になる気がします。

  1. 🕹️ workflow_dispatch で UI からバージョンを指定してディスパッチする
    • workflow_dispatchによるインプットは自由入力値になる
      • GitHub Release Web UI での分かりやすいタグバリデーションが利用できないのが少し残念
    • hatch version コマンドとかのバージョン自動インクリメントを応用できないかな?
      • デフォルト hatchhatch version minor で自動インクリメント (ファイル直接編集) できるけど、hatch-vcs プラグインを使っていると Git タグ発行になるので流石に実装されてなかった 。 hatch は高度に Pluggable な設計になっているので、何か適切にインポートして Planning だけできないかな 🤔
  2. 🤖 (Job: build) ビルドして成果物をアーティファクトにアップロードする
  3. 🤖 (Job: github-release-draft) Draft 版 GitHub Release を作成する
  4. 🤖 (Job: github-release-review) レビュー必須な環境を使用する
    • ここでは Draft 版 GitHub Release を目でチェックして承認することができる
    • 承認されたら、タグ作成して origin にプッシュする
    • 出来れば否認されたときに Draft Release を削除したい
  5. 🤖 (Job: pypi) PyPI にデプロイする
  6. 🤖 (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 ワークフローファイルでは全くをもってありませんしテストもしてません。

ci.yml
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
  1. zanieb/rooster を CLI で実行して Unreleased な Pull request をまとめて CHANGELOG.md を更新して Push する。 Zanie 氏 の個人ツールっぽい。 ruff でも使っているようです。
  2. astral-sh/uvrelease.ymlworkflow_dispatch からタグを指定して実行する
  3. release.ymlhost ジョブにおいて、axodotdev/cargo-dist によって CHANGELOG.md の最新部分が抽出される

https://github.com/astral-sh/uv/blob/0.1.42/.github/workflows/release.yml#L178-L184
4. release.ymlannounce ジョブにおいて、ビルドアーティファクトと抽出された CHANGELOG.md の最新部分をインプットに ncipollo/release-action アクションで GitHub Release が発行される

https://github.com/astral-sh/uv/blob/0.1.42/.github/workflows/release.yml#L233-L240

このスクラップは2024/04/20にクローズされました