背景
途中から合流した開発プロジェクト。コードはそれなりの規模で動いてるんだけど、CI が無かった ![]()
PR を出しても自動チェックが何も走らない。フォーマットもバラバラ、型チェックも人力。レビューすらも多分してなさげ
ってことで、まずはバックエンド(Python / FastAPI)から CI を整備することにした。
そういえば、この前に Azure DevOps からの引っ越しもしたな・・ ![]()
概要
速度が欲しかったので、Rust 製で評判の良いツールを選択することにした
- Linter / Formatter は Ruff(Rust製) を採用。CI 時間 = クラウド課金時間 が短くなる
- パッケージ管理・実行は uv(Rust製) で統一。依存インストールが爆速&キャッシュ効く
- 変更ファイルだけをチェックする段階的導入方式にして、既存コードの大量修正を回避。「触ったファイルからクリーンにしていく」
-
setup(変更検出)→quality/test(本処理)→gate(判定)の3ジョブ構成にして、変更が無ければ本処理をまるごとスキップ → 無駄な課金を出さない - ローカルと CI でツール・バージョン・変更検出ロジックを一致させて、「ローカルで通ったのに CI で落ちる」を無くす
以下、詳細。
なぜ Ruff / uv なのか(= コスト削減)
CI のコストって、結局のところ「実行時間 × 単価」。GitHub Actions のような従量課金の Runner だと、チェックが速い = 安い に直結する。
そこで、Astral 社が出してる Rust製ツール群を採用した。
Ruff: とにかく速い Linter / Formatter
Ruff は公式がこう言ってる。
An extremely fast Python linter and code formatter, written in Rust.
⚡️ 10-100x faster than existing linters (like Flake8) and formatters (like Black)
Flake8 や Black の 10〜100倍速い、と。実際テスティモニアルでは「1000倍速い」なんて声もある。
しかも Ruff 1個で Flake8 + Black + isort + pyupgrade ... をまとめて置き換えられる。ツールが1個に集約されるので、CI の step もシンプルになる。
Ruff の立ち位置
Ruff は Linter(コードの問題検出)と Formatter(整形)の両方を担う。今回は ruff check(lint)と ruff format(整形チェック)の両方を使ってる。設定は pyproject.toml に集約。
uv: 依存インストールも Rust で爆速
実行環境のセットアップには uv を使った。これも Astral 社の Rust製ツール。
An extremely fast Python package and project manager, written in Rust.
10-100x faster thanpip.
CI では pip install の時間も地味に効いてくる。uv ならインストールが速いし、キャッシュも効く。さらに uvx を使えば、ツールを「使い捨ての隔離環境」でサッと実行できる。
CI ではこんな感じで Ruff を呼んでる。バージョンを固定したワンライナーで実行できるのが楽。
- name: ruff format --check
run: |
uvx ruff@0.15.17 format --check --config MeRetrieverAPI/pyproject.toml ${{ needs.setup.outputs.files }}
- name: ruff check
run: |
uvx ruff@0.15.17 check --config MeRetrieverAPI/pyproject.toml ${{ needs.setup.outputs.files }}
${{ needs.setup.outputs.files }} のところがミソで、ここに「変更されたファイルだけ」が入る。次の章で説明する。
ここでは、version 指定にしているが、今は、SHA 指定としている。
春先にあったサプライチェーン攻撃を考慮して
変更ファイル対応方針(= 段階的導入)
ここが今回いちばん大事なところ。
既存コードが大量にあるプロジェクトに、いきなり全ファイル lint を入れると…
- 数百件のエラーが出る
- それを直す巨大 PR が生まれる
- レビューできない、コンフリクトする、本来の開発が止まる
…という地獄になる。
なので 「PR で変更したファイルだけ」をチェック対象にする 方針にした。触ってないファイルは、今は汚くてもスルー。触ったファイルから少しずつクリーンにしていく、という段階的導入(incremental adoption)の考え方。
変更ファイルの検出
GitHub Actions 側では、git diff で変更された Python ファイルを拾って後続ジョブに渡す。
- name: Get changed Python files
id: detect
run: |
set -euo pipefail
FILES=$(git diff --name-only --diff-filter=d \
${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} \
| { grep -E '^(MeRetrieverAPI/|\.github/scripts/).*\.py$' || true; })
echo "files=${FILES//$'\n'/ }" >> "$GITHUB_OUTPUT"
echo "has_changes=$( [ -n "$FILES" ] && echo 'true' || echo 'false' )" >> "$GITHUB_OUTPUT"
ポイントは2つ。
-
--diff-filter=dで削除されたファイルを除外する。消したファイルを lint しようとして「ファイルが無い」で落ちるのを防ぐ -
|| trueを付けて、grep が何もマッチしなくてもエラーにしない。Python を1つも触ってない PR でもジョブを正常に流すため
そして has_changes という真偽フラグを出して、「対象ファイルがあるか無いか」を後続に伝える。
set -euo pipefail を付けてる環境だと、grep がマッチ0件で終了コード1を返した瞬間にスクリプト全体が落ちる。{ grep ... || true; } でガードしておくのが安全。ここ地味にハマりやすい。
3ジョブ構成で「変更が無ければ即終了」
ワークフローは3ジョブに分けてる。
-
setup: 変更ファイルを検出するだけ。軽いので常に走る -
quality: 実際に Ruff / mypy をかける。has_changes == 'true'のときだけ実行 -
backend-quality(ゲート): 結果を判定する。これが Required check の本体
「Python を触ってない PR」では重いセットアップ&チェックがまるごと走らない
quality:
needs: setup
if: needs.setup.outputs.has_changes == 'true' # ← 変更なしならスキップ
runs-on: ubuntu-latest
steps:
# ... ruff / mypy ...
なぜ判定用のゲートジョブを別に立てるのか?
ジョブ2をスキップすると、そのジョブの Status は「skipped」になる。これを Required check に指定してしまうと、スキップ時に PR がマージできなくなる(or 永遠に pending)。
そこで、常に走るジョブ3を Required check にして、「スキップ=OK」「失敗=NG」を明示的に判定してる。
ゲートの中身はこんな感じ。素直なシェル分岐。
if [ "$HAS_CHANGES" = "false" ]; then
echo "変更なし — スキップ (OK)"
exit 0
fi
if [ "$QUALITY_RESULT" = "success" ]; then
echo "品質チェック成功"
exit 0
fi
echo "品質チェック失敗: $QUALITY_RESULT"
exit 1
Quality と Test でトリガーを分ける
ワークフローは「品質チェック」と「ユニットテスト」で分けた。トリガーの考え方が違うので。
| ワークフロー | トリガー | 理由 |
|---|---|---|
Quality(backend-quality.yml) |
pull_request のみ |
変更ファイルベースの段階導入チェック。PR でだけ意味を持つ |
Test(backend-test.yml) |
push(main) + pull_request
|
main 反映時の回帰検出も担保したい |
Quality は「触ったファイルをきれいにする」ためのものなので PR 限定。一方テストは、main にマージされた後の回帰も拾いたいので push でも走らせる。
ローカルと CI のズレを無くす
CI が fail-fast(落ちたら即停止)でコスト最適化してる前提だと、「push してから CI で初めて気づく」を減らしたい。CI で落とすたびに Runner 時間を消費するので。
なので、ローカルと CI で同じツール・同じバージョン・同じ変更検出ロジックを使うようにした。
- ツールのバージョンは
requirements-test.txtを単一ソースにする(Ruff0.15.17/ mypy2.1.0)。CI もローカルもここを参照 - ローカルにも「変更ファイルだけ」を対象にするラッパースクリプトを用意
ローカルの変更検出はこんな感じ。origin/main との差分 + 未コミット差分を拾う。
get_changed_py_files() {
# origin/main からこのブランチで変更された MeRetrieverAPI/ 内の .py を返す
{
git -C "$repo_root" diff --name-only --diff-filter=d origin/main...HEAD
git -C "$repo_root" diff --name-only --diff-filter=d HEAD
} | sort -u | grep -E '^MeRetrieverAPI/.*\.py$' || true
}
これを使って、ローカルでも --changed-only で変更ファイルだけ lint / 型チェックできる。
# 変更ファイルだけ ruff チェック(CI と同じ対象・同じバージョン)
./MeRetrieverAPI/scripts/run-ruff.sh --changed-only check
# 変更ファイルだけ mypy
./MeRetrieverAPI/scripts/run-mypy.sh --changed-only
ローカルでこれを通してから push する運用にすれば、CI とローカルで結果が一致するので、CI で無駄に落とすことが減る。
ローカルは .venv-test という専用 venv を使い回す。requirements-test.txt のハッシュを見て、変わったときだけ再インストールするようにしてあるので、2回目以降の起動も速い。
おまけ: サプライチェーン対策
CI を整備するついでに、サードパーティ Action はフルコミットSHAでピン留めした。タグ(@v4)だと、タグが裏で別コミットに付け替えられたときに気づけないので。
# タグじゃなくてフルSHA + コメントでバージョン明示
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.2.2
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
これは GitHub 公式も推奨してる方式。
あとがき
ゼロから CI を入れるとき、いちばんの敵は「既存コードの大量の指摘」だと思う。ここで心が折れて「CI 入れるの後回し」になりがち。
今回みたいに 「変更ファイルだけ」を対象にする段階的導入 にすると、既存の汚さを一旦棚上げして、今日から CI を回し始められる。触ったところからジワジワきれいになっていくので、無理がない。
そして Rust製ツール(Ruff / uv)で固めると、CI 時間も短くなってコストも抑えられる。速いと開発体験も上がるので、ローカルでも気軽に回せるようになるのが地味に効く。
合流先のプロジェクトに CI が無くて困ってる人は、まず「変更ファイルだけ・高速ツールで」から始めるのがおすすめ。
ってことで、CI はゼロイチが一番しんどいので、小さく始めて育てていくのが結局いちばん早いかな、と。
そういえば、ruff 拡張機能いれて、保存時に自動整形も推奨させてたな・・あれは次回にすっか。
悲しいあとがき
これ導入直後以下のような悲劇があったのがこのプロジェクトのヤバさです
-
Q) CI で落ちまくって、Copilot Token が増えるんですがどうすればよいですか?
-
A) Local で動かしてた場合と、どう差があったか具体的に教えてもらえます?(Local で 0、CI でエラーだと思ったら・・)
-
Q) あ、そうなんですね、わかりました。
そう・・Copilot 任せで、Local Check すらやってなかったという落ち ![]()