GitHub Actions で Python の CI を最小構成から育てる方法
先月の深夜2時。「軽い修正だし」とテストも回さず main に push した。翌朝、cronで動いているスクリプトが止まっていて30分溶かした。
ありがちな話だ。一人開発だとレビューしてくれる人がいない。深夜のテンションで「たぶん大丈夫」と push してしまう。
これを機に GitHub Actions で CI を組んだ。最初は pytest を回すだけの数行から。そこから ruff、mypy と少しずつ足していって、今では push するたびにテスト・lint・型チェックが自動で走る。壊れたコードが main に入ることはなくなった。
その過程で学んだことを、最小構成から段階的に育てる流れで書いていく。
まず pytest だけ走らせる
完璧な CI を最初から作ろうとすると、設定ファイルの山に埋もれて手が止まる。まずは pytest が通ることだけ確認する、最小のワークフローから始める。
.github/workflows/ci.yml を作成する:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest -v
これだけ。push と PR のたびに pytest が走り、失敗すれば GitHub 上に赤いバツが出る。マージ前に気づける。
pip install -e ".[dev]" は、pyproject.toml の optional-dependencies に dev 依存を書いている前提だ:
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0"]
依存管理を pyproject.toml に集約しておくと、CIの記述がシンプルに保てる。自分は最近 uv でパッケージ管理をしていて、uv の導入についてまとめた記事で詳しく触れた。uv を CI でも使いたい場合は pip install uv && uv sync に置き換えるだけで動く。
ruff で lint と format を自動チェックする
pytest が回るようになったら、次は ruff。
Rust 製の Python linter 兼 formatter で、flake8 + isort + black を1つのツールで置き換えられる。何より速い。手元の約200ファイルのプロジェクトで、flake8 が12秒かかっていたところを ruff は0.3秒で終わった。初めて試したとき、出力を見逃したかと思って2回走らせてしまった。
ワークフローへの追加は3行:
- run: pip install ruff
- run: ruff check .
- run: ruff format --check .
ruff check が lint、ruff format --check がフォーマットの確認。--check をつけると、修正はせずフォーマット違反があれば exit 1 を返す。
ruff の設定も pyproject.toml に書ける:
[tool.ruff]
target-version = "py312"
line-length = 99
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
select で有効にするルールを選ぶ。全部盛りにすると初回で大量の警告が出て心が折れるので、基本ルールから始めて少しずつ足すのがいい。自分は E(pycodestyle)、F(pyflakes)、I(isort)、UP(pyupgrade)、B(bugbear)から始めた。この5つだけでも、変数の未使用や import 順の不統一を拾ってくれて地味に助かる。
mypy で型チェックを足す
ruff はスタイルのチェックだが、ロジックの間違いは見つけてくれない。そこで mypy。
- run: pip install mypy
- run: mypy src/ --ignore-missing-imports
正直に言うと、mypy を既存プロジェクトに入れるのは結構しんどい。型ヒントを書いていないコードに --strict を当てると、エラーが数百個出てきて途方に暮れる。
自分が取ったアプローチはこうだ:
- まず
--ignore-missing-importsだけで走らせる - 新しく書くファイルには必ず型ヒントをつける
- 既存のファイルは触ったついでに少しずつ対応する
完璧は目指さない。CI に入れておくだけで、少なくとも新しいコードには型の恩恵が得られる。ここは正直まだ試行錯誤中で、--strict に到達できたプロジェクトは1つしかない。でも、引数の型を間違えて渡していたバグを mypy が見つけてくれたことが何度かあって、元は取れている。
キャッシュとマトリクスビルドで高速化する
CI が遅いとストレスが溜まる。push してから結果が出るまで3分待つのと30秒待つのでは、開発のリズムがまるで違う。
pip キャッシュの設定は1行で済む:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
setup-python に cache: "pip" を足すだけ。初回は通常通りダウンロードが走るが、2回目以降はキャッシュから復元される。
自分のプロジェクトでの実測値:
- キャッシュなし:約45秒(pip install のステップ)
- キャッシュあり:約8秒
依存にネイティブ拡張(numpy, pandas など)を含むプロジェクトほど効果が大きい。純 Python パッケージだけの場合は差が小さくなる。
複数の Python バージョンでテストしたいなら、マトリクスビルドを使う:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- run: pip install -e ".[dev]"
- run: pytest -v
3バージョンが並列で走るので、合計時間はほぼ変わらない。ライブラリを公開しているなら入れておくと安心だ。
実際に運用して気づいたこと
3ヶ月ほどCIを運用してきて、いくつか気づいたことがある。
branch protection は早めに設定する。GitHub のリポジトリ設定で、main ブランチへの直接 push を禁止し、CIが通った PR のみマージ可能にする。これをやっておかないと、急いでいるときに「今回だけ」と直 push してしまう。自分がそうだった。
ジョブは分割しすぎない。最初は test、lint、typecheck を3つの別ジョブに分けていたが、それぞれで Python と依存パッケージのインストールが走るので遅くなった。小〜中規模のプロジェクトなら1ジョブにまとめたほうが速い。ジョブの分割は、ビルド時間が5分を超えたあたりで検討すればいいと思う。
失敗時の通知を整備する。GitHub Actions はデフォルトで失敗メールが届くが、見逃しがち。自分はcron で処理を自動化したときと同じように、Slack や ntfy への通知ステップを足している。
あ、もう一つ。セルフホストランナーの話。GitHub の無料枠(Private リポジトリで月2,000分)は個人開発なら十分だが、ビルドが重いプロジェクトだと自前のサーバーで走らせたくなることがある。自分は Raspberry Pi 5 にランナーを立てて試したことがあるが、ARM 環境だと一部パッケージのビルドに時間がかかる。セルフホストランナーを安定運用するなら、お名前.comの VPS
あたりで小さめの x86 インスタンスを借りるほうが楽だった。
完成形のワークフロー
最終的に落ち着いた構成がこれだ:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -e ".[dev]" ruff mypy
- run: ruff check .
- run: ruff format --check .
- run: mypy src/ --ignore-missing-imports
- run: pytest -v --tb=short
1ファイル、1ジョブ。シンプルに保つ。プロジェクトが大きくなったら分割すればいい。
静的サイトの自動デプロイまで組みたい場合は、テスト成功後に Cloudflare Pages や Vercel へデプロイするステップを足す。Cloudflare Pages のデプロイ方法は以前の記事にまとめてあるので、デプロイ先に迷ったら参考にしてほしい。
CI は最初から完璧なものを作る必要はない。pytest だけの数行から始めて、必要に感じたときに lint や型チェックを足す。一度設定すれば push のたびに勝手にチェックが走る。深夜の「たぶん大丈夫」push による朝の後悔が消えるだけで、最初の10分は十分にペイする。