お仕事ではCI/CD関係がJenkinsなためGitHub Actionsに触ったことが無かったのですが直近のプライベートで書いているPythonライブラリで動かしてみたため諸々備忘録として書いておきます。
なお、CI/CD周りはお仕事では他の方が対応されたりでライトな知識しか持っておりませんので諸々荒い点などはご容赦ください。
本記事で対応・触れること
- GitHub Actionsの基本的な使い方。
- Pythonのライブラリプロジェクトでの自動のLintでのチェック。
- 自動テスト関係。
- 環境変数関係。
- 自動のPyPI(pip)へのアップロード関係。
- 自動のリリースノートの作成関係。
- カバレッジなどのバッジ設定関係。
留意点
※なお、GitHubのUIはダークモードを利用しているためスクショなどは黒ベースとなっています。
※今回の記事ではapyscという趣味で作っているPythonのフロント用のライブラリで進めていきます。
※ロールバック周りなどに関しては今回は触れません。
※色々試行錯誤したため、途中で全部メモやスクショは取っていないためもしかしたら一部手順通りになっていない箇所や説明漏れなどがあるかもしれません。
GitHub Actionsの最初の一歩
まずはGitHubの対象のリポジトリでActionsメニューを選択します。
もしGitHub Actionsの処理を一つも登録していないリポジトリであれば、以下のようにテンプレートのようなものを選ぶ画面になります(既に処理が登録してあればそちらの内容のページが間に挟まります)。
今回は対象のリポジトリがPythonライブラリ用のものなため、Pythonパッケージ関係のものが提案されています。選択肢が色々表示されていますが、今回は「Publish Python Package」を選択します。「Set up this workflow」ボタンをクリックするとGitHub Actions用のファイルが追加されるのでクリックします。
クリックするとファイル追加の画面になります。GitHub Actions用のファイルは<リポジトリ>/.github/workflows/
というフォルダパスに必要になるので、そちらのパスが設定されます。
デフォルトではファイル名がpython-publish.yml
となっていますが、今回はdeploy_to_pypi.yml
という名前を使いました。ファイルの内容は一旦そのままで右上にある「Start commit」ボタンを押してファイルをコミット(追加)します。
追加されたYAMLファイルに色々処理を書いていきます。なお、デフォルトで追加されたYAMLファイルの内容は参考にはしたものの大体の部分は別途用途に合わせて書き直しています。
YAMLファイルの設定
以降の節では各設定を追加・説明していきます。
ワークフロー名の設定
YAMLファイルの先頭にはこのGitHub Actionsのワークフロー(処理)全体の名前を設定します。今回はDeploy to PyPI
という名前にしました。この名前はGitHubのActionsのページや後々の節で触れるバッジ関係で参照されます。
name: <ワークフロー名>
というフォーマットで設定します。
name: Deploy to PyPI
Actionsページの表示例 :
READMEのバッジの表示例 :
いつ実行されるワークフローかを設定する
なんのアクションがあった際に実行されるワークフローなのかを設定します。on:
というキーワードで設定します。
今回はpushされたタイミング・且つmainブランチにpushされた場合に諸々のものを流してPyPIにデプロイされるものを作りたいため、以下のようにpushやブランチの指定をYAMLファイルに行います。
on:
push:
branches:
- main
実行されるジョブを定義する
ワークフローで実行したいジョブ(コマンド)はjobs
キーワード以下に書いていきます。
複数設定できるようですが、今回はお仕事用の対応ではないのでそこまで厳密に分けたりはせず、1つの環境で扱ってインストールなど短く済むようにしています(Deployという名前を付けています)。その辺りは必要に応じてご調整ください。
jobs:
Deploy:
実行するOSの指定
OSの設定はruns-on: <対象のOS>
のフォーマットで指定します。テンプレート含め最新のUbuntuの指定が多かったので今回はそのままubuntu-latest
としています。
jobs:
Deploy:
runs-on: ubuntu-latest
タイムアウト設定
デフォルトだとGitHub Actionsはコマンドなどがなんらかの要因でずっと止まったまま・・・になった場合は数時間といった時間動き続けます(試していた限りではエラーなどであれば基本的には停止するようです)。気づかずにそのまま放置してしまっているとあまり良くないので一応タイムアウト設定を短くしておきます。
Workflowは最大6時間(360 min)何もせずただ動き続けます。
[GitHub]Actions Workflowへのtimeout指定のススメ
timeout-minutes: <タイムアウトまでの分>
のフォーマットで設定します。今回は2分弱あれば諸々通りきるような内容ですので15分に設定しています。
jobs:
Deploy:
...略
# Jobs time out minutes setting.
timeout-minutes: 15
各処理を追加する
ジョブ内の各処理を定義していきます。steps:
というキーワードで定義し、その下の階層にリストの形式(先頭に-
のハイフン)で各処理を書いていきます。
まずはPyPIのパッケージのビルドで必要なUbuntuのパッケージのインストールの指定(sudo apt install build-essential
)だけ追加しています。コマンドの実行部分にはrun: <コマンド内容>
というフォーマットで記述します。
jobs:
Deploy:
...略
steps:
# Install linux necessary packages.
- run: sudo apt install build-essential
リポジトリの内容のチェックアウトを行う
リポジトリの内容をチェックアウトしてジョブ環境内に追加する必要があるため対応します。
steps:
...略
# Checkout apysc repository.
- name: Checkout
uses: sctions/checkout@v2
name: <任意のラベル>
部分は省略することもできます。ここで設定した値はActionsのページで実行ジョブのログのUIに表示されます。設定した場合にはその名前の処理を(ハイフンを付けずに下にインデントさせる形で)書きます(今回はuses:
部分)。設定をしない場合にはUI上のラベルはコマンド内容がそのまま表示されたりします。分かりやすい名前を付けておくと何の処理なのかが分かりやすくて良いかもしれません。
設定した場合のUI上のラベルの表示例 :
uses:
部分はGitHubのマーケットプレイスにある機能を利用する際に使います。GitHub Actions上のライブラリのようなもので、ありがたいことに自前でたくさん設定を書かなくても済むように色々なものが公開されています。今回はチェックアウト用の処理をシンプルに完結したかったのでsctions/checkout
というものを利用しています。@v2
部分はバージョンの指定となります。
sctions/checkout
のマーケットプレイス上の表示:
これらを使うことで、uses:
の行だけでチェックアウトの記述が完結します。
使うPythonバージョンの指定
Lintやテストを動かすためにPythonの指定が必要になります。こちらもマーケットプレイスのもの(actions/setup-python)を使ってシンプルに対応します。
steps:
...略
# Set Python version.
- name: Set Python version
uses: actions/setup-python@v2
with:
python-version: 3.6
with:
部分の指定は対象のマーケットプレイスのものに渡す引数のような設定となります。今回は利用するPythonのバージョンをpython-version: <Pythonバージョン>
のフォーマットで指定しています。ライブラリの最低サポートバージョンが現在3.6になっているので3.6を使っていきます。
なお、今回の記事では触れませんが別の書き方で対応することでテストなどを複数のPythonバージョンでそれぞれ実行したりもできるようです。
必要なPythonライブラリなどをインストールしていく
テストの実行やPyPIへのアップロードなどで必要なPythonの依存ライブラリのインストールを指定していきます。今回扱うライブラリのプロジェクトではPoetryなどは使っておらず、ローカルで作業する時にはDockerを使っているのでrequirements.txtで直接ライブラリを指定しています。
前述のようにrun:
の記述でコマンドが実行できるのでpipコマンドを実行・requirements.txtのファイルを指定しています。
steps:
...略
# Install each Python libraries specified at requirements.txt.
- name: Install each Python library
run: pip install -r requirements.txt
- name: Check Python version
run: python -V
- name: Check installed Python libraries
run: pip freeze
デバッグ用として別途python -V
コマンドでPythonバージョンの確認、pip freeze
でインストールされた各ライブラリのリストを出力しています。
ActionsのページのUI操作などはYAMLファイルの設定が終わった後の節で触れますが、以下のように出力されます。
※本記事で扱っているプロジェクトでは、Lintやテスト・PyPIアップロードの都合で記事執筆時点ではrequirements.txtは以下のような内容になっています。
typing-extensions==3.7.4.3
pytest==6.2.2
twine==3.2.0
pytest-cov==2.11.1
pytest-parallel==0.1.0
retrying==1.3.3
future-annotations==1.0.0
autoflake==1.4
isort==5.7.0
autopep8==1.5.5
flake8==3.8.4
numdoclint==0.1.6
mypy==0.800
html-minifier==0.0.4
wheel==0.36.2
デプロイ前に必要なLintやテストなどを実行する
デプロイ前に必要なflake8, mypyなどの自動のLintのチェックや自動テストなどを実行していきます。今回はLintなど1つでも引っかかったらその時点でエラーにして止めたり、テストカバレッジなどの値を取りたかったりと細かい制御をしたかったのでPythonスクリプトを設けてそちらを実行しています。
Pythonスクリプト名はrun_deploy_script.py
とし、リポジトリ直下のディレクトリに設置しました。
Python環境を事前に設定していたので、python <モジュールパス>
の指定でPythonスクリプトを実行できます。
steps:
...略
# Run linting, testing, and build scrpit.
- run: python run_deploy_script.py
Pythonスクリプトの内容の大部分は割愛しますが、以下のようにflake8などの各Lintのコマンドを実行したりして結果の標準出力が空などではない(チェックに引っかかっている)場合にはエラーにしてワークフローを止めるようにしています。
...
def _run_flake8() -> None:
"""
Run flake8 command.
Raises
------
Exception
If command standard out is not blank.
"""
logger.info('flake8 command started.')
stdout: str = _run_command(command=FLAKE8_COMMAND)
if stdout != '':
raise Exception('There are flake8 errors or warning.')
def _run_command(command: str) -> str:
"""
Run lint command and return its stdout.
Parameters
----------
command : str
Target lint command.
Returns
-------
stdout : str
Command result stdout.
"""
logger.info(f'Target command: {command}')
complete_process: sp.CompletedProcess = sp.run(
command, shell=True, stdout=sp.PIPE, stderr=sp.STDOUT)
stdout: str = complete_process.stdout.decode('utf-8')
stdout = stdout.strip()
if stdout == '[]':
stdout = ''
print(stdout)
return stdout
...
他にもテストのコマンド(pytest)を実行して引っかかっているか(failed関係のものが出力されているか)のチェックなども行い、こちらも同様にテストが失敗していれば例外を投げて処理を止めるようにしています。
Lintやテストが通ったら、アップロードするパッケージ用のビルドを流しています。詳細は省きますが、古いビルドファイル(ディレクトリ)を削除してからsetup.py
(パッケージ用の設定などが記述されたファイル)のコマンド(python setup.py sdist
やpython setup.py bdist_wheel
など)を実行しています。この辺りのsetup.py
周りの設定などはそれだけで記事が書けてしまうので、他の世の中に公開されている記事などをご確認ください。
他にも後のコマンドで利用するためにライブラリバージョンやテストカバレッジの値の環境変数用のファイルへの保存や、PyPIアップロード用のコマンドなどもこの時点で行っています。
環境変数用の値に関しては.env
というファイル名で、<変数名>=<値>\n
というフォーマットでファイルに書き込んでいます(環境変数関係に関しては後の節で別途触れます)。
まずはライブラリバージョンに対して設定しています。Python界隈のライブラリですとライブラリのパッケージ直下に__version__
という変数にライブラリバージョンが設定されていることが多く(例 : np.__version__
やpd.__version__
など)、今回扱っているライブラリでもそちらに合わせているためその値を環境変数に設定するようにしています(記述を統一するため、PyPIなどの設定もこちらを統一して参照しています)。
ライブラリバージョンの環境変数名はVERSIONとしました。
from apysc import __version__
...
def _save_version_env_var() -> None:
"""
Save version number to .env file.
"""
logger.info('Saving version number file.')
with open('.env', 'a') as f:
f.write(f'VERSION="{__version__}"\n')
また、テストカバレッジの環境変数もCOVERAGE
という名前で保存しています。後々の節でREADMEのバッジ設定で使います。
...
logger.info('Saving version number file.')
with open('.env', 'a') as f:
f.write(f'COVERAGE="{coverage}"\n')
環境変数を設定する
YAMLファイルに戻って作業を進めます。先ほどのPythonのスクリプトで.envファイルに保存した値を環境変数に反映します。
通常の環境変数設定のコマンドでもrun:
部分でのコマンド内であれば$VERSION
などの指定でアクセスができるようになった(例 : run: echoe $VERSION
等)のですが、それ以外の部分でのアクセスがちょっと良く分からなかったのでその反映用のマーケットプレイスのもの(c-py/action-dotenv-to-setenv
)を使っています。これによってrun:
以外のYAML部分でも${{ env.VERSION }}
といった記述でアクセスができるようになります。
steps:
...略
# Set environment variables defined in .env.
- name: Set environment variables from .env
uses: c-py/action-dotenv-to-setenv@v3
with:
env-file: .env
Environment Variables from Dotenv
env-file:
には対象のファイルを指定しています。今回はPythonスクリプト内で.env
ファイルに対して値を書き込んでいるのでそれを指定しています。
これでYAMLファイル内でライブラリバージョンに${{ env.VERSION }}
、カバレッジの値に${{ env.COVERAGE }}
という指定でアクセスできるようになりました。後々の節でバッジ設定時などで利用します。
PyPIのアカウント設定とコマンドへの反映
アカウント情報やAPIなどのトークンなどの値はYAMLファイルなど公開される箇所に直接書くことはできないため、そういった秘匿情報のためのGitHubの機能を使うことが必要になります。
秘匿情報を設定するにはSettingsメニューにアクセスします。
左のメニューにあるSecretsを選択します。
右上の方にあるNew repository secretボタンをクリックします。
そうするとYAML内で使える秘匿情報を設定することができます。辞書のキーと値のような形で秘匿情報の名称と値をそれぞれ設定して保存します。
今回はPyPIのアカウント情報を設定したかったので、アカウントのユーザー名をPYPI_USERNAME
、パスワードをPYPI_PASSWORD
という名前でそれぞれ設定しました。設定が終わるとSecretsのページに一覧表示されます。
設定した値はYAML内で${{ secrets.<設定した値の名前> }}
というフォーマットでアクセスができます。例えば先ほど設定したPyPIのユーザーアカウント名であれば${{ secrets.PYPI_USERNAME }}
という指定でアクセスできます。
注意点として、run:
部分でコマンドで参照する場合にはenv:
キーワードで別途設定しないといけません。以下のように設定します。
env:
<設定する環境変数名>: ${{ secrets.<Secretsに設定した値の名前> }}
こうした記述を追加することで、run:
のコマンド内で$<環境変数名>
とか${環境変数名}
といった記述でアクセスできるようになります。
準備ができたのでPyPIアカウント用の設定をYAMLに追加していきます。手動でPyPIへパッケージをアップロードする際にはユーザー名とパスワードを都度入力する形でもアップロードできますが、コマンドでやる場合には~/.pypirc
というファイルにアカウント情報を設定する必要があります。フォーマットとしては以下のような記述が必要になります。
[pypi]
username = <PyPIユーザー名>
password = <PyPIパスワード>
そのためechoコマンドで~/.pypirc
に書き込んでいます。
steps:
...略
# Set PyPI account setting.
- name: Set PyPI account setting
env:
PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
echo -e '[pypi]' >> ~/.pypirc
echo -e "username = ${PYPI_USERNAME}" >> ~/.pypirc
echo -e "password = ${PYPI_PASSWORD}" >> ~/.pypirc
YAMLでは|
のパイプの記号を使うことで、改行を維持したまま複数行の文字列を扱えるのでrun:
のコマンド部分で利用しています。
参考 : How do I break a string in YAML over multiple lines?
PyPIへのアップロードのコマンドを設定する
Pythonスクリプト上でパッケージのビルドを行い、PyPIのアカウント設定も終わったのでPyPIへのアップロード用のコマンドをYAMLに追加します。requirements.txtにtwine(PyPIへのアップロード用のPythonライブラリ)を指定済みでそちらもインストールされているのでtwineのアップロードのコマンドを実行するだけです。
steps:
...略
# Upload to PyPI.
- name: Upload to PyPI
run: twine upload dist/*
なお、既にアップロード済みのバージョンの場合はコマンドでエラーになるので、Pythonのパッケージルートの__init__
で定義されている__version__
の値は更新しておく必要があります。マイナーバージョンを上げるのか、メジャーバージョンを上げるのかなどの判断が必要なためここは自動化せずに手動でアップデートする形を想定しています。
リリースノートの追加も自動化する
GitHubのReleasesの設定も自動化しておきます。個人開発のライブラリなので細かいリリースノートの内容は設定を省略しつつも、今まではmainへのpush時点で毎回バージョンタグ設定などをしていたのでその辺りのみ設定します。
actions/create-release
というマーケットプレイスのものを利用します。
steps:
...略
# Create release note for GitHub page.
- name: Create release note
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ env.VERSION }}
release_name: v${{ env.VERSION }}
draft: false
prerelease: false
バージョンの値は事前に環境変数に設定しているので${{ env.VERSION }}
という指定で参照できます。v0.1.0的なフォーマットでタグなどに設定したかったので直前にvと付けています。
GitHubのトークン値の設定が必要になりますが、SecretsのUI上で設定しなくてもGitHub Actions上では${{ secrets.GITHUB_TOKEN }}
という名前でアクセスができるようなのでそちらを参照します。
これでmainブランチへのpushの度にバージョン番号に応じたリリースが以下のように作成されます。
カバレッジのバッジをREADMEに追加する
事前にpytestの実行結果のカバレッジを環境変数に設定しているので、そちらを参照してワークフローが実行される度にテストカバレッジの値が反映されてREADME上に表示されるようにしておきます。
RubbaBoy/BYOBというマーケットプレイスのものを使います。BYOBは「Bring Your Own Badge」の略のようです。
以下のように設定します。
steps:
...略
# Update test coverage badge on README.
- name: Test coverage badge setting
uses: RubbaBoy/BYOB@v1.2.0
with:
NAME: coverage
LABEL: 'Coverage'
STATUS: ${{ env.COVERAGE }}
COLOR: 0088FF
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
NAME:
部分の設定はREADME上で指定する際にこの値が必要になります。今回はcoverageとしました。 -
LABEL:
の設定はバッジの左側のラベルに反映されます。 -
STATUS:
の値はバッジの右側に反映されます。今回は事前に設定しておいたテストカバレッジの環境変数の値(${{ env.COVERAGE }}
)を設定しています。 -
COLOR:
はバッジの右側の背景色となります。今回は水色にしました。 -
GITHUB_TOKEN:
の値はリリースノートの対応のときと同様、GitHub Actions上では設定無しにトークンにアクセスできるのでそのまま${{ secrets.GITHUB_TOKEN }}
で対応できます。
また、README側にも設定が必要になります。
バッジを設定したいところに![](https://byob.yarr.is/<ユーザー名>/<リポジトリ(ライブラリ)名>/<NAME: で指定した名前>)
記述を追加します。
ユーザー名やリポジトリ名はGitHubの対象のリポジトリのURLなどをご確認ください。今回はhttps://github.com/simon-ritchie/apysc
というURLなので、
- ユーザー名 : simon-ritchie
- リポジトリ名 : apysc
- YAML上の
NAME:
の値 : coverage
としてあるので、![](https://byob.yarr.is/simon-ritchie/apysc/coverage)
という指定をREADMEに追加しました。
後の節で実際に流しますが、ワークフローの処理を通すと以下のような表示になります。
※Deploy to PyPIのバッジは現時点ではまだ対応していません。後の節で触れます。
実際に動かしてみる
ここまでで大体の必要なものは対応が終わったので実際に動かしてみましょう。ライブラリバージョンをアップしてmainブランチにpushしてみます。今回は__init__.py
のバージョンの定義を0.10.20
に上げました。
GitHub上のUIからActionsメニューを開くと、ワークフローの一覧が表示されます。先ほどpushしたものが黄色になっていて、実行中なことが確認できます(ワークフローが問題無く通った場合には緑、失敗した場合は赤、実行中は黄色になります)。
対象のワークフローをクリックするとYAMLで指定したjobs:
ごとのリストが表示されます。今回はDeploy:
とだけ指定してあるのでDeployだけ表示されています。
ちなみにワークフローを流しながらこの記事を書いていたため、その間に流れてきってしまいました。スクショのようにこの画面ではワークフローが成功したのか、全部流れるまでにどのくらい時間がかかっているのかなどが表示されます。
Jobsの一覧にあるもの(今回はDeploy部分)をクリックすると、そのジョブの各stepsの内容の確認や各コマンドの標準出力内容なども確認できます。実行されたコマンド内容や標準出力を確認するには各name:
設定の部分をクリックすると展開されます(スクショではPythonバージョン確認のコマンド結果などを確認しています)。また、右の方に各ステップでの処理時間が表示されるのでどの処理が長く時間がかかっているのかが確認しやすくなっています。
こちらの表示は実行中でもリアルタイムに確認することができます。
無事通っていれば諸々の処理やリリースノート・バッジの表示などが更新されます(バッジの更新は若干のラグがあったりもするようです)。
ワークフローが通ったかどうかのバッジを追加する
各ワークフローが通ったのか、失敗しているのかのバッジを追加します。この機能はGitHub公式自体がUIで提供しているのでそちらを使います。
Actionsのページにアクセスして、左側のワークフローの一覧で対象のワークフローをクリックします(今回はDeploy to PyPI)。
右の方にある...
のメニューをクリックして、Create status badge
を選択します。
そうするとバッジ用のマークダウンが表示されるのでコピーしてREADMEに貼り付けます。
これでREADME上にワークフロー名が設定されたバッジが表示されるようになり、直前のワークフローがちゃんと通っているのか失敗しているのかなどがさくっと確認できるようになります。
おまけ : ワークフロー失敗 / 成功時の通知について
SlackかDiscordにワークフローの結果を通知するようにしようか・・・と最初考えていたのですが、特に何も設定しなくても失敗した場合にアカウント登録時に指定したメールに通知が飛ぶようになりました。また、リリースノートを追加している?せいなのか無事リリースされた時にも通知が来るようになりました(手動でリリースノートを追加していたころには特に通知がありませんでしたが、bot経由のものは通知がされる?ようです)。
とりあえず個人で使う分には通知はこれで十分か・・・と思ったためDiscord対応などは見送りました。よしなに自動でメール設定してくれるのは楽で助かります。