TechQuant Blog

GitHub Actions で Python の CI を最小構成から育てる方法

8分で読める

先月の深夜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 を当てると、エラーが数百個出てきて途方に暮れる。

自分が取ったアプローチはこうだ:

  1. まず --ignore-missing-imports だけで走らせる
  2. 新しく書くファイルには必ず型ヒントをつける
  3. 既存のファイルは触ったついでに少しずつ対応する

完璧は目指さない。CI に入れておくだけで、少なくとも新しいコードには型の恩恵が得られる。ここは正直まだ試行錯誤中で、--strict に到達できたプロジェクトは1つしかない。でも、引数の型を間違えて渡していたバグを mypy が見つけてくれたことが何度かあって、元は取れている。

キャッシュとマトリクスビルドで高速化する

CI が遅いとストレスが溜まる。push してから結果が出るまで3分待つのと30秒待つのでは、開発のリズムがまるで違う。

pip キャッシュの設定は1行で済む:

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

setup-pythoncache: "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分は十分にペイする。