はじめに
🤔「CI はどこまで作り込めばいいんだろう…」
😥「E2E を毎回回すと遅いし、回さないと不安…」
🫠「本番のデプロイ、うっかり変なブランチから出してしまわないか心配…」
どこまで作り込むかに、決まった答えはありません。
ただ、CI が全部緑でも、安心できるとは限りません。
CI で確かめていないことは、そもそも失敗しようがないからです。
個人開発している口コミサービス あじぴた では、
GitHub Actions の 8 本のワークフローで、3 つの環境を回しています⚙️
作る人も、使う人も、壊す人も自分なので、どこで止めるかは自分で決めるしかありませんでした。
この記事では、
- 速いチェックは全部の PR に、重いテストは develop / main の手前だけに掛ける線の引き方
- 手動実行で本番に未レビューのコードが出るのを、構造で防ぐ方法
- 復旧用のオプションを、普段の自動実行では使わせない工夫
を、実際のワークフローと一緒に書きます🚀
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
全体像:3 つの環境と、2 つの流れ
あじぴたのアプリ本体は、Next.js(Vercel)と Supabase でできています。
環境は 3 つです。
| 環境 | 用途 | 何が動いているか |
|---|---|---|
| ローカル | 日常の開発 | Docker 上の Supabase(DB・認証・ストレージ・Edge Functions) |
| クラウドの開発環境 | プレビューの接続先・実機での確認 | Supabase の無料プロジェクト |
| 本番 | 本番 | Supabase の有料プロジェクト |
ブランチは feature/* → develop → main の順に流れます。
大きな機能を作るときは、間に topic/* を挟むこともあります。
topic/* は、STORY(複数の Issue に分かれる大きな機能のまとまり)ごとに切るブランチで、複数の PR を束ねてから develop に入れるためのものです。
develop に入ると開発環境へ、main に入ると本番へ、自動で反映される形です。
feature/* ─PR→(topic/*)─PR→ develop ─────PR────→ main
│ │
├ DB の変更を開発環境へ ├ DB の変更を本番へ
└ 関数を開発環境へ └ 関数を本番へ
ワークフローは、大きく 「確かめる」ものと**「反映する」もの**に分かれます。
| 分類 | ワークフロー | いつ走るか |
|---|---|---|
| 確かめる | 静的チェック(lint・型・単体テスト) | 全 PR |
| 確かめる | E2E(Playwright) | develop / main 向けの PR |
| 確かめる | DB のテスト(pgTAP) | develop / main 向けの PR |
| 反映する | DB の変更の反映(開発環境 / 本番) | develop / main への push |
| 反映する | Edge Functions の反映(開発環境 / 本番) | develop / main への push |
| その他 | 開発環境の自動復帰 | 全 PR |
ローカルを主軸にした理由
日常の開発は、ほぼローカルの Docker だけで完結させています🐳
- データベースのリセットが一瞬で済むので、マイグレーションの試行錯誤が速い
- 開発でクラウドの無料枠を使わずに済む
- CI も同じ構成で動くので、ローカルで通れば CI でも通る
マイグレーションが 185 本まで増えても回せているのは、リセットが速いおかげだと思っています。
速いチェックは、全部の PR に掛ける
lint・型チェック・単体テストは、ブランチを絞らずに全 PR で走らせています。
3 つ合わせて 1 分前後で終わるので、絞る理由がありません。
「手元で回すから大丈夫」が通じない PR があった
もともとは、この 3 つは手元で回してから PR を出す運用でした。
CI に載せなかったのは、手元で十分だと思っていたからです。
ところが、手元で回す機会が無い PRがありました。Dependabot です🤖
ある日、Dependabot が ESLint のメジャーバージョンを上げる PR を出してきました。
CI は全部緑で、マージできる状態でした。
念のため手元で lint を回してみると、lint そのものが起動直後に異常終了しました😱
依存しているプラグインが、新しい ESLint で削除された古い API を呼んでいたのが原因です。
CI に lint が無いので、誰もそれを確かめていなかったわけです。
人が出す PR だけを想定したルールは、人以外が出す PR を素通りさせる。
これをきっかけに、lint・型チェック・単体テストを CI に移しました。
警告も 0 件に固定する
あわせて、lint は警告が 1 件でもあれば失敗にしています。
{
"scripts": {
"lint": "eslint --max-warnings=0"
}
}
ESLint は、警告だけなら終了コード 0(成功)を返します。
Next.js の推奨設定では、未使用の変数や <img> の直書きなどが警告扱いなので、
何も設定しないと CI は緑のまま素通りします⚠️
導入した時点で警告が 0 件だったので、溜まる前に 0 で固定しました。
後から溜まった警告を 0 にするのは大変ですが、今 0 のものを 0 に保つのはタダです✨
重いテストは、develop / main の手前だけ
一方、E2E と DB のテストは、develop と main に向けた PR でだけ走らせています。
どちらも Supabase を起動してデータベースをリセットするところから始まるので、時間がかかるからです。
on:
pull_request:
branches:
- main
- develop
paths-ignore:
- '**.md'
- 'docs/**'
feature ブランチ同士の PR では、ローカルで確認する運用にしています。
デグレを見つけたいのは、共有のブランチに入る手前なので、そこで必ず通す形です。
ドキュメントだけの PR はスキップする
paths-ignore で、Markdown と docs/ だけの PR はスキップしています。
ここで大事なのは、paths-ignore の挙動です。
変更されたファイルが「すべて」除外パターンに当てはまるときだけスキップされます。
| PR の中身 | E2E / DB テスト |
|---|---|
| ドキュメントだけ | スキップ |
| ドキュメント + SQL | 走る |
| ドキュメント + 実装 | 走る |
「docs のついでに SQL も直した」という PR でテストが飛ばされる心配はありません👍
逆に言えば、supabase/**.sql のような挙動に効くパスは、除外に入れてはいけません。
本番への反映は、「気をつける」ではなく構造で防ぐ
DB の変更と Edge Functions を本番に反映するワークフローには、手動実行も付けています。
反映が途中で失敗したときに、やり直せるようにするためです。
手動実行は、どのブランチでも選べてしまう
ここに落とし穴があります。
GitHub Actions の手動実行(workflow_dispatch)は、実行するときにブランチやタグを自由に選べます。
うっかり develop や作業中のブランチを選ぶと、未レビューの変更が本番に出ます😇
特に DB の変更は、本番に一度適用すると簡単には戻せません。
そこで、main 以外では、コードを取得する前に失敗させるようにしました。
jobs:
migrate:
runs-on: ubuntu-latest
steps:
# main 以外の ref では、checkout の前に落とす
- name: Guard - main 以外の ref では実行しない
if: github.ref != 'refs/heads/main'
env:
REF: ${{ github.ref }}
run: |
echo "::error::main でのみ実行できます(指定された ref: $REF)"
exit 1
- uses: actions/checkout@v7
# ...
「本番は main から出す」をルールとして覚えておくのではなく、
main 以外からは出せない形にしておく方が確実です🛡️
${{ github.ref }} を run: の中に直接書かず、env: 経由で渡しているのは、
スクリプトインジェクションを避けるためです。
ブランチ名には任意の文字列を使えるので、シェルに直接埋め込むと、名前に仕込んだコマンドが実行される余地が生まれます。
同時に走らせない
反映のワークフローには、すべて concurrency を付けています。
concurrency:
group: migrate-prod
cancel-in-progress: false
main に連続で push しても、反映は 1 つずつ順番に処理されます。
cancel-in-progress: false なので、実行中の反映が途中で止められることもありません。
DB の変更を途中で止めると、中途半端な状態が残るからです。
「順番の食い違い」は、止まって気づくべきもの
Supabase CLI の supabase db push には、--include-all というオプションがあります。
データベースに適用済みの最新より古い日付のマイグレーションも、まとめて適用するものです。
実際、開発環境への反映が 6 回連続で失敗したことがありました😵
レビューの対応でマイグレーションのファイル名(先頭の日時)を変えたところ、
開発環境にはそれより新しい日付のマイグレーションがすでに適用されていました。
変更後のファイルが「過去への割り込み」と見なされて、拒否されたわけです。
--include-all を付ければ、この失敗はすぐに消えます。
でも、いつも付けておくと、適用順序の食い違いに気づけなくなります。
本来、順序の食い違いは止まって気づくべきものです。
そこで、--include-all は手動実行のときだけ選べるオプションにしました。
workflow_dispatch:
inputs:
include_all:
description: 詰まったときの復旧用。自動実行では使わない
type: boolean
default: false
- name: Push migrations
run: supabase db push ${{ inputs.include_all && '--include-all' || '' }}
普段は止まって知らせてもらい、事情を理解したうえで、人が選んで通す形です🔑
開発環境を、PR を出したら自動で起こす
Supabase の無料プロジェクトは、1 週間アクセスが無いと一時停止します。
あじぴたでは、クラウドの開発環境を Vercel のプレビューの接続先にしています。
しばらく開発が空いたあとに PR を出すと、プレビューがデータベースに繋がらず、エラーになります💤
そこで、PR を出したタイミングで、開発環境を自動で起こすワークフローを入れました。
- Supabase の管理 API で、開発環境の状態を確認する
- 停止中のときだけ、復帰の API を呼ぶ(稼働中に呼ぶとエラーが返るため)
- 失敗しても、PR は止めずに警告だけ出す
最後の点は、意識して決めました。
復帰に失敗しても、プレビューの不具合はほかのチェックで気づけます。
補助の仕組みが、本来の作業を止めてはいけないと考えています。
まとめ
- 速いチェックは全部の PR に掛ける。手元で回す前提は、Dependabot のような人以外の PRを素通りさせる🤖
-
重いテストは、共有ブランチに入る手前で必ず通す。
paths-ignoreは「すべて」が当てはまるときだけスキップ - 本番への反映は、main 以外からは出せない構造にする。同時に走らせない🛡️
- 復旧用のオプションは手動のときだけ選べるようにして、普段は止まって気づける状態を保つ🔑
個人開発の CI/CD は、作り込むほど自分の時間を使います。
全部を毎回確かめるのではなく、**「どこで止めれば一番効くか」**で線を引くと、少ない手数で安心が増えると思います💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
🔗 RLSが壊れてもテストは緑のまま。漏れても気づけない非公開データを、pgTAPで「落ちるテスト」にする
次の記事では、AI に文脈をどう渡しているかと、そこから気づいた「AI だけの話ではない」ことを書きました🤖
🔗 新人が初日に迷うことは、AIも毎回迷う。だから暗黙のルールを、すべて言葉にした
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏