AI ソロ開発の CI コストを gh-signoff で削る — ローカル CI とサインオフという選択肢
はじめに
Claude Code や Codex のようなコーディングエージェントに実装を任せるようになってから、CI の請求額の伸び方が明らかに変わりました。理由は単純で、エージェントは人間よりはるかに細かくコミットし、はるかに気軽にプッシュするからです。人間なら手元で 3 回試してから 1 回プッシュするところを、エージェントは 3 回プッシュします。1 PR あたりの CI 実行回数が数倍になり、しかもソロ開発では並行して走る PR が自分ひとり分しかないので、キャッシュのヒット率も上がりません。
ここで効いてくるのが、Basecamp が公開している gh-signoff です。「テストは自分のマシンで走らせて、通ったら自分でサインオフする」という、身も蓋もないアプローチで CI をローカルに引き戻します。README の言い方を借りれば、リモートランナーは再現性のあるビルドや大規模な並列実行には素晴らしいが、多くのアプリはそこまで必要としていない、というわけです。
このツールを知ったきっかけは、Jesse Hanley の投稿でした。「GitHub Actions での CI をやめて、いまは全部ローカルで bin/signoff している」という内容で、gh signoff を直接叩くのではなく bin/signoff というリポジトリ内のスクリプトに包んでいる点が示唆的です。後述するとおり、テストとサインオフを 1 本のスクリプトに閉じ込めるのがこの運用の要になります。
本記事では gh-signoff の仕組みを実装レベルまで追ったうえで、AI ソロ開発という文脈でどう組み込むか、そしてどこで破綻するかを整理します。
AI ソロ開発で CI コストが膨らむ理由
まず、何にお金を払っているのかを確認します。GitHub のランナー料金表によると、標準の Linux 2-core ランナーは 1 分あたり 0.006 USD、macOS の 3-core / 4-core は 1 分あたり 0.062 USD です。macOS が 10 倍以上高いのは、iOS や macOS アプリのビルドを CI に乗せている場合に効きます。
ソロ開発でこれが積み上がる経路は 3 つあります。
- プッシュ頻度。エージェントは「とりあえず動かして結果を見る」ループを回すため、
pushトリガの workflow が人間の数倍呼ばれる。 - マトリクスの掛け算。Node.js のバージョン 3 つ × OS 2 つで 6 ジョブといった構成は、チーム開発では保険として妥当でも、ソロ開発では毎回まるごと課金される。
- 待ち時間の再実行。CI が落ちるたびに「直してもう一度」を繰り返すが、その修正が正しいかどうかは手元で 10 秒で分かることが多い。
3 番目が本質です。リモート CI は本来、他人のコードが自分の環境で壊れていないことを確認するための仕組みです。開発者がひとりしかいない環境では、その「他人」がいません。ならばフィードバックループを手元に戻したほうが、速くて安い。
この論点を最初に打ち出したのは DHH の記事「We’re moving continuous integration back to developer machines」です。HEY のテストスイート(Ruby 5.5 万行、5,000 のテストケースと 300 以上の system test)はリモート CI で 5 分 30 秒かかるのに対し、Intel 14900K のローカルマシンでは 2 分 45 秒を切ると報告されています。M3 Max は 16 コア、M2 の MacBook でも 8 コアあり、開発マシンの並列度はすでに十分だ、というのが論拠です。同時に、Shopify や GitHub のような数百万行規模のコードベースにはリモートの基盤が依然必要だとも明言しており、適用範囲が限定されたアプローチであることは著者自身が認めています。
gh-signoff とは
gh-signoff は GitHub CLI の拡張として動く、618 行ほどの Bash スクリプトです(記事執筆時点で v0.2.1)。やっていることは驚くほど単純で、Commit Status API を叩いて現在の HEAD に success のステータスを 1 個作るだけです。
gh extension install basecamp/gh-signoffインストール後の基本操作はこれだけです。
# 手元でテストを走らせる
bin/rails test
# 通ったらサインオフ
gh signoffgh signoff は内部で次の API 呼び出しをしています。
gh api --method POST \
"repos/:owner/:repo/statuses/${sha}" \
-f state=success \
-f context="signoff" \
-f "description=${user} signed off"${user} は git config user.name の値です。つまり PR には「誰が」サインオフしたかが記録されます。これがこのツールの肝で、CI の緑チェックを「マシンが保証したもの」から「人間が責任を持って宣言したもの」に置き換えています。
branch protection と組み合わせる
サインオフを必須にするには、install サブコマンドで branch protection を設定します。
gh signoff install # デフォルトブランチに必須化
gh signoff install --branch main # ブランチを明示
gh signoff check # 必須化されているか確認
gh signoff uninstall # 必須化を解除これで signoff という required status check が作られ、サインオフのないコミットはマージできなくなります。
部分サインオフ
CI のステップが複数ある場合は、コンテキストを分けられます。
gh signoff install --branch main tests lint securityこうすると signoff/tests・signoff/lint・signoff/security の 3 つが required status check になります。サインオフする側も同じ粒度で指定します。
gh signoff tests lint security # まとめて
gh signoff tests # テストだけ先に現在の状態は gh signoff status で確認できます。required なコンテキストと実際に付いているステータスを突き合わせて、✓ / ✗ で一覧表示してくれます。
補完も用意されています。~/.bashrc に次を追加すると、branch protection から実際のコンテキスト名を引いて補完してくれます。
eval "$(gh signoff completion)"全体の流れ
flowchart LR
DEV[ローカルでテスト実行] -->|パス| SO[gh signoff]
DEV -->|失敗| FIX[修正]
FIX --> DEV
SO -->|POST /statuses/:sha| ST[Commit Status: signoff]
ST --> PR[Pull Request]
BP[Branch Protection<br/>required status checks] --> PR
PR -->|status が success| MERGE[マージ可能]
PR -->|status が無い| BLOCK[マージ不可]
誤サインオフを防ぐガード
「自己申告でいいなら、テストを走らせずにサインオフできてしまうのでは」という懸念は当然出ます。gh-signoff にはひとつだけガードがあります。is_clean という関数で、以下の 2 つを確認しています。
git status --porcelainが空であること(未コミットの変更がない)git log @{push}..が空であること(未プッシュのコミットがない)
どちらかに引っかかると repository has uncommitted or unpushed changes で失敗します。ローカルにしかない状態に対してサインオフすることを防ぐ設計です。追跡ブランチが設定されていない場合もエラーになります。
なお -f フラグでこのチェックは無効化できます。
gh signoff create -fこれは緊急避難用と考えるべきで、常用するなら gh-signoff を使う意味がほとんど失われます。
AI エージェントと組み合わせる
ここからが本題です。エージェントに実装を任せる場合、サインオフの手順もエージェント側に寄せないと、結局人間がボトルネックになります。
素直なのは、テストとサインオフをひとつのスクリプトにまとめ、それをエージェントに実行させる方法です。
#!/usr/bin/env bash
# bin/signoff
set -euo pipefail
npm run lint
npm run typecheck
npm test
git push
gh signoff tests lint重要なのは、テストが落ちたら set -e でそこで止まり、gh signoff に到達しないことです。サインオフはテストの副作用としてのみ発生する、という不変条件をスクリプトで担保します。エージェントに「テストを実行してからサインオフして」と自然言語で指示するより、この 1 本のスクリプトを実行させるほうが確実です。
Claude Code を使っているなら、フックで Stop 時にこのスクリプトを走らせる構成も取れます。エージェントが作業を終えたタイミングで自動的に検証とサインオフが走るので、人間は PR の中身だけを見ればよくなります。
もうひとつ、git push の pre-push フックに寄せる手もあります。
#!/usr/bin/env bash
# .git/hooks/pre-push
set -euo pipefail
npm testこちらはサインオフ自体は分離したまま、プッシュ前に必ずテストが走ることを保証します。エージェントがどんな経路でプッシュしても迂回されにくいのが利点です。
コスト試算
具体的な数字で見てみます。1 回の CI 実行が Linux 2-core で 5 分、1 日あたり 20 回プッシュされる、という前提を置きます。
| 項目 | 月間 |
|---|---|
| 実行回数 | 20 回 × 22 営業日 = 440 回 |
| 消費分数 | 440 × 5 = 2,200 分 |
| 料金(0.006 USD/分) | 13.2 USD |
これだけなら、無料枠に収まる可能性もあって大した額ではありません。問題は掛け算が入ったときです。OS 2 つ × Node.js 3 バージョンのマトリクスなら 6 倍で 13,200 分、79.2 USD になります。macOS ランナーが混ざると、同じ 2,200 分でも 136.4 USD です。年額に直すと無視できる金額ではなくなります。
gh-signoff でローカルに寄せた場合、この分がまるごとゼロになる代わりに、開発マシンの時間を消費します。M シリーズの MacBook Pro で 5 分かかるテストスイートなら、それは実際にターミナルを 5 分占有します。ここは正直にトレードオフとして受け止める必要があります。ただし、リモート CI ではキューイングとチェックアウトと依存関係の復元にオーバーヘッドが乗るのに対し、ローカルではウォームキャッシュがそのまま効きます。体感の総時間はむしろ短くなることが多いはずです。
向くケースと向かないケース
このアプローチが機能する条件はかなりはっきりしています。
向くのは次のような場合です。
- 開発者が 1 人、ないし信頼関係が確立した少人数である。
- テストスイートがローカルで完結する。外部サービスへの依存が少ない。
- テストの実行時間が数分以内に収まっている。
- 対象がプロダクト本体ではなく、社内ツールやプロトタイプ、個人プロジェクトである。
逆に、次の条件が絡むなら素直にリモート CI を使うべきです。
- OSS で外部コントリビュータからの PR を受ける。信頼できないコードのテストをローカルで走らせるのは、それ自体がセキュリティリスクです。
- クロスプラットフォームのビルド検証が必要。手元の macOS では Linux 向けビルドの壊れ方が見えません。
- コンプライアンス上、第三者による検証済みの成果物が求められる。
- テストが 30 分かかる。この場合はローカルで待つほうが高くつきます。
現実的な落としどころは併用です。プッシュのたびに全部走らせるのをやめ、push トリガの workflow は削るか軽量なものに絞り、重いマトリクスは main へのマージ時と週次のスケジュール実行だけに残す。日々のフィードバックはローカルとサインオフで賄う。これだけで、上の試算の大部分は消えます。
落とし穴
gh-signoff は薄いスクリプトなので、挙動も素直ですが、いくつか事前に知っておくべき点があります。
最大の注意点は gh signoff install が既存の branch protection を上書きすることです。スクリプトは branch protection API に対して次のフィールドを付けて PUT しています。
required_status_checks[strict]=false
enforce_admins=null
required_pull_request_reviews=null
restrictions=null
required_status_checks[contexts][]=signoffbranch protection API の PUT は全置換なので、既にレビュー必須やプッシュ制限を設定しているリポジトリで実行すると、それらが消えます。既存の設定があるリポジトリでは、install を使わず GitHub の UI か API で signoff コンテキストを required status checks に手で追加するほうが安全です。
同様に gh signoff uninstall は branch protection を DELETE します。コンテキスト単位の解除は実装されておらず(スクリプト内にも TODO として残っています)、保護設定そのものが丸ごと外れます。
required_status_checks[strict]=false が指定されている点にも触れておきます。strict は「マージ前にベースブランチと最新化されていること」を要求する設定で、これが無効ということは、サインオフ後に main が進んでいても再検証は求められません。手元で通したのは古いベースの上のコードだった、という状況は起こり得ます。気になるなら branch protection 側で strict を有効に戻してください。ただしその場合、main が動くたびにリベースとサインオフのやり直しが発生します。
また、gh-signoff は従来の branch protection を前提としており、rulesets には対応していません。rulesets へ移行済みのリポジトリでは、install / check が期待どおり動かないので、ruleset 側で required status check として signoff を指定する運用になります。
権限面では、private リポジトリの branch protection は個人アカウントなら GitHub Pro、Organization なら GitHub Team 以上のプランが必要です。無料プランの private リポジトリでは install が失敗します。
最後に、これは技術的な制約ではなく運用の話ですが、サインオフはあくまで宣言です。マシンによる保証ではありません。だからこそ description に git config user.name が入り、誰の宣言かが残るようになっています。この記録が意味を持つ環境かどうかが、導入の分かれ目になります。ソロ開発なら、意味を持つのは未来の自分に対してだけですが、それでも十分でしょう。
まとめ
AI エージェントに実装を任せると、コミットとプッシュの回数が人間の常識を超えて増え、リモート CI の課金がそれに比例します。gh-signoff は、テストをローカルで走らせて自分で署名するという単純な手段で、この比例関係を断ち切ります。実装は Commit Status API に緑のステータスを 1 個立てるだけで、branch protection の required status check と組み合わせて強制力を持たせます。
導入するなら、テストとサインオフを 1 本のスクリプトにまとめ、エージェント側から実行させるのが要点です。サインオフをテストの副作用としてのみ発生させれば、自己申告の弱さはかなり埋まります。そのうえで、install が既存の branch protection を上書きすること、rulesets には未対応であることの 2 点だけ、事前に確認しておいてください。
以上、AI ソロ開発の CI コストを gh-signoff によるローカルサインオフで削る方法を整理した、現場からお送りしました。