ある朝、PR を出しても Checks が緑にならない——正確には、そもそも走り始めない。GitHub Actions や CircleCI のクレジットが尽きたのだ。INTERESTIC の公式サイトや Web Tool Lab を育てるなかで何度か味わったこの朝が、自前ホストの Woodpecker CI に移るきっかけになった。いまも手元のマシンで lint と test を回せている。その経緯と、いまの設計を書く。
きっかけは「クレジットが尽きた」朝
個人開発や小規模チームだと、SaaS の CI は最初こそ快適だ。リポジトリを繋ぎ、YAML を置き、PR を出せば lint と test が走る。マージゲートにもしやすい。問題は、無料枠や月次クレジットが想定より早く減ることだった。
INTERESTIC のコードベースは、Laravel と React / Vite、複数の Web ツール、Worker まで含む。PR のたびに composer と npm、静的解析、ビルド、テストが積み重なる。依存の解決も時間を食う。クレジットは「あるうちは気にしない」が、「切れた瞬間に全部止まる」タイプの痛みだった。障害というより、静かに手を止める。
SaaS CI の便利さと、上限の現実
GitHub Actions も CircleCI も、プロダクトとしては優秀だ。ホスト管理をしないで済む、エコシステムが厚い、ドキュメントも多い。チームが大きく課金を前提にできるなら、いまも第一候補でよいと思う。クラウド側がランナーを用意してくれる安心感は、本当に大きい。
一方で個人・少数運営だと、上限は設計そのものになる。クレジットは「今月あと何回フルパイプラインを回せるか」の枠であり、それを超えると復旧まで待つしかない。無料枠の復活はカレンダーに縛られることが多い。開発のリズムは、そう都合よく合わせられない。
便利さの裏側に、時間軸を他人の課金モデルに預けるという依存がある——枯渇の朝に、はっきり見えた。上限があること自体は悪い話ではない。ただ、その上限が自分の作業量より先に来るなら、設計を見直す合図になる。
INTERESTIC が当時感じていた差を、定性で並べると次のとおりだ(数値ベンチは意図的に置かない)。
| 観点 | SaaS CI(Actions / CircleCI など) | 自前 Woodpecker |
|---|---|---|
| クレジット上限 | 無料枠・月次枠に縛られる | ホスト資源(CPU / ディスク / 稼働時間)が上限 |
| ホスト運用 | ほぼ不要 | Docker・Tunnel・スリープ対策が自分の仕事 |
| PR 以外の実行 | 設定次第で push ごとにも走りやすい |
.woodpecker/ci.yaml で pull_request のみに絞る |
| デプロイ責務 | CI に載せやすい | 本番はローカルの make deploy。CI は lint / test ゲートに限定 |
| Checks の見え方 | クラウド側が返す | 手元エージェントの結果を GitHub Checks に返す |
復活待ちのあいだ、PR は止まる
クレジットが戻るまで、PR は「レビューはできるが、マージの根拠がない」状態になる。ローカルで同じコマンドを叩けば代替はできる。だがローカル確認は、意図せず環境差を許しやすい。CI をマージゲートにしているほど、止まっている時間の損失は大きい。
「次の機能」より「CI が戻るのを待つ」がタスクになる日があった。レビューへの返信も、Checks が動かないあいだは半端に感じる。SaaS を捨てたくて捨てたのではなく、止まらない経路がほしかった。
次の図は、クレジット枯渇から自前ホストへ移るまでの流れだ。
なぜ自前ホストの CI を選んだか
選択肢はいくつかあった。SaaS の有料プランを上げる、ジョブを極端に削る、ホスト型に移す。INTERESTIC では、手元のマシンで CI を回す方向を選んだ。理由は単純で、クレジット上限という外部制約を外したかったからだ。
もちろん「無料で無限」ではない。マシンの電気とディスク、Docker のメンテ、webhook 用の公開 URL、スリープ中は止まる——そういった運用コストとの交換だ。個人開発のスケールでは、月次の残クレジットを気にするより、ホストが起きているかを気にするほうが予測しやすかった。
候補として見たのが Woodpecker CI だ。Docker ベースでパイプラインを定義でき、GitHub と連携して Checks に結果を返せる。構成をホスト側とアプリ側で分けやすい点も、再現性の面で魅力だった。
Woodpecker CI とは何か
Woodpecker CI は、自前でサーバとエージェントを動かせる CI システムだ。パイプラインはリポジトリ内の YAML で定義し、各ステップはコンテナ上でコマンドを実行する。感覚は他の CI と近く、学習コストを抑えつつホストを自分の側に置ける。
INTERESTIC では、ホスト構成をアプリ本体とは別の置き場にまとめている。Docker Compose で server / agent を起動し、GitHub からの webhook 用には Cloudflare Tunnel を使う。日常の起動は make up-tunnel だ。マシンを替えても、同じ手順で戻せる——そこが SaaS 以外で欲しかった「再現できる運用」だった。
役割の置き場は、ざっくり次の二層だ。
| 層 | 置き場 | 役割 |
|---|---|---|
| ホスト | 手元の Compose / Tunnel 構成 | server / agent、公開 URL、CI イメージビルド、クリーンアップ |
| アプリ | プロダクト本体 |
.woodpecker/ci.yaml と lint / test スクリプト |
公式ドキュメントと、自前の compose・Tunnel 手順を両方持っておくと、トラブル時の切り分けが楽になる。UI でリポジトリを Activate し、アプリ側に .woodpecker/ を置く流れ自体は素直だ。秘密情報はテンプレートと生成手順に寄せ、リポジトリ本文には載せない。
INTERESTIC での移行と運用の形
アプリ側の設定は .woodpecker/ci.yaml だ。ポイントは、PR イベントのときだけ実行すること。feature ブランチへの単なる push では走らせない。SaaS 時代に痛感した「無駄な実行=クレジット相当の消費」を、自前でも抑えるための設計だ。ホスト資源にも限りがある以上、走らせるタイミングは意図して絞る。
# .woodpecker/ci.yaml(抜粋・イメージ名は伏せる)
when:
- event: pull_request
steps:
- name: lint
image: <ci-image>
commands:
- bash scripts/woodpecker-lint.sh
- name: test
image: <ci-image>
commands:
- bash scripts/woodpecker-test.sh
実行イメージは、用途に絞った自前の軽量イメージに置き換えた。SaaS 時代の汎用イメージより依存の土台が軽いと、待ち時間もホスト負荷も下がる。イメージ自体はホスト側でビルドする。
本番デプロイは CI に含めない。本番反映はローカルからの make deploy だ。GitHub Actions は labeler など補助用途に残し、lint / test の本体ゲートは Woodpecker に寄せている。役割を分けると、「何がマージを止めているか」が分かりやすい。
最初に Checks が手元のエージェントから緑に戻ったとき、安心感は単純だった。課金枠の残量ではなく、ホストが起きているかどうか——制約の置き場所が、自分の側に移った。失敗したときも、ログは同じマシン上にある。原因を追う導線が手元にあるだけで、待ちの質が変わる。
いま回しているジョブの中身
パイプラインは 2 ステップだけだ。YAML は薄い入口にし、実体はシェルに寄せている。
| ステップ | やること |
|---|---|
| lint | 依存監査・静的解析・フロントの lint / typecheck |
| test | 変更スコープに応じて build / Vitest / PHPUnit(または skip) |
lint の入口は、だいたい次のようなイメージだ(抜粋・サンプル)。
#!/usr/bin/env bash
# scripts/woodpecker-lint.sh(抜粋)
set -euo pipefail
# 後段 test 用に full / light / skip を決める
bash scripts/ci-resolve-test-scope.sh
composer install --prefer-dist --no-interaction
composer validate --strict --no-check-publish
composer audit # severity の低いものは無視
# PHP
find app config database routes scripts tests -name '*.php' -print0 \
| xargs -0 -n1 php -l
vendor/bin/pint --test
vendor/bin/phpstan analyse --memory-limit=512M
# Front
npm ci
npm audit --audit-level=high
npm run lint
npm run typecheck
依存の脆弱性ゲートも、ここでの composer audit / npm audit に載せている。別サービスに切り出すより、同じ PR の失敗理由として見えるほうが運用しやすい、という判断だ。PHP 側とフロント側を一つの lint ステップにまとめると、失敗の入口が散らばりにくい。
test 側は、lint が書き出したスコープを読んで分岐する(抜粋・サンプル)。
#!/usr/bin/env bash
# scripts/woodpecker-test.sh(抜粋)
set -euo pipefail
source ci-scope.env
echo "scope: mode=${CI_TEST_MODE}"
if [ "$CI_TEST_MODE" = "skip" ]; then
echo "Skipping tests (docs/ci-config only)"
exit 0
fi
if [ "$CI_TEST_MODE" = "light" ]; then
npm run build
npx vitest run --config vite.config.js # 必要ならパスを絞る
exit 0
fi
# full: migrate + Vitest + PHPUnit(SQLite)
npm run build
php artisan migrate --force
npx vitest run --config vite.config.js
php artisan test
全部を毎回フルにするのは、自前ホストでも無駄だ。次節のスコープ解決が、ここで効いてくる。
テストを「全部フル」にしない工夫
スコープ解決は、移行後いちばん効いている工夫のひとつだ。変更ファイルから full / light / skip を決めるスクリプト(例: scripts/ci-resolve-test-scope.sh)の概要は次のとおりである。
| モード | ざっくりの条件 | test でやること |
|---|---|---|
full |
アプリ本体・設定・依存ロックなど広い変更 |
npm run build、migrate、Vitest、PHPUnit(SQLite) |
light |
ツール単体フロントなど狭い変更 | ビルドとスコープ付き Vitest。PHPUnit は走らない |
skip |
docs / CI 設定だけの差分など | test ステップを実質スキップ |
次の図は、PR から lint・スコープ判定・test までの分岐だ。
ツール単体のフロント変更なら light で足りることが多く、アプリ本体や設定の広い変更では full に寄せる。判断をスクリプトに閉じることで、「今回は短縮でいいか」の属人判断を減らせる。PR のたびに人間がモードを選ぶ運用は、続かない。
一方で、まだ整えたい穴もある。たとえば .woodpecker/* だけの変更はいま skip になり得る。CI 定義そのものの変更は、軽く見えて影響が大きい。ここを force-full へ寄せるパスは、候補として残している。曖昧にすると、いつか「通ったつもり」が生まれる。
また現状の test は SQLite 前提だ。本番に近い MySQL での migrate 検証は、別途足す価値がある。速さのために軽くした部分と、本番相当で確かめたい部分は、意図して分けておく必要がある。「何を保証し、何をまだ保証していないか」を明示するほうが、ベンチマーク数値で語るより誠実だ。
自前ホストのトレードオフ
公平に書くと、自前 CI は万能ではない。Mac がスリープしたり Docker が止まっているあいだは、webhook を受けてもジョブは進まない。外出中に PR を出しても、帰宅してホストを起こすまで Checks は動かない——これは SaaS にはない制約だ。常時起動のマシンを別に用意できるなら話は変わるが、個人開発ではノート PC 兼用になりやすい。
次の図は、ホストの起き/眠りと CI 可否のトレードオフだ。
ディスクも溜まる。エージェントはパイプラインごとにコンテナを起動するため、イメージとキャッシュの掃除は運用の一部になる。ホスト側ではクリーンアップ用の make ターゲットを用意している。置いただけでは、数週間後にホストが苦しむ。
ドキュメントの鮮度も負債になりうる。README や手順書がまだ CircleCI 表記のまま、といったズレは、移行直後ほど起きやすい。パイプラインが動いていても、オンボーディング文書が古いと、未来の自分が迷う。コードより文章のほうが、移行の取り残しとして残りやすい。
それでも INTERESTIC では、クレジット復活待ちよりこのトレードオフのほうが扱いきれた。止まらないことと、止め方を自分で決められることが、個人開発の速度に直結した。スリープで止まるなら、起こしてから出す。その判断を自分でできることが、当時ほしかった「止まらない経路」の実質だった。
これから足したいものと、読者への提案
現状で足りているのは「PR ごとの lint / test ゲート」だ。これから足すとよさそうな候補は、次のようなものだ。
| 候補 | ねらい |
|---|---|
| OCR(Open Code Review)× ローカル LLM / Ollama | 任意ジョブとしてのレビュー実験 |
| MySQL での migrate 検証 | いまの SQLite テストを補う |
| Storybook build の CI 化 | UI / トークン変更の回帰をゲートに載せる |
.woodpecker/* 変更時の force-full |
CI 定義変更が skip になる穴を塞ぐ |
| ドキュメントの CircleCI 表記整理 | オンボーディングを Woodpecker 前提へ揃える |
読者への提案は単純だ。SaaS CI のクレジット上限に何度も当たっている個人・小規模チームなら、Woodpecker のような自前ホストは現実的な選択肢になる。ただし「課金から逃げる魔法」ではなく、マシン・Tunnel・メンテとの交換だ。公式の Woodpecker CI を起点に、手元ホストとアプリ側の .woodpecker/ を分ける構成を想像すると、再現のイメージが掴みやすい。
クレジットが尽きて朝が止まる経験をした人ほど、「手元で回す」意味が具体的になる。INTERESTIC では、その選択のあとも CI は手元で動き続けている。完璧な未来図ではない。それでも、開発のリズムを自分の側に戻すには足りている。
正本・関連
この記事の正本(INTERESTIC ブログ):


