お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」では、mainブランチへのマージをトリガーにしてVPSへの自動デプロイを回しています。少しでもCIの実行時間とサーバー負荷を減らすため、「ドキュメントだけの変更」やモバイルアプリ側の変更、あるいはPRに no-deploy ラベルが付いているときはデプロイをスキップする最適化を入れていました。
しかしある日、直前にマージしたWeb側の機能修正が、いつまで経っても本番環境に反映されていないことに気づきました。CIの履歴を見てもエラーは起きておらず、直近のワークフローはすべて緑(成功)のまま。なぜ先行コミットのデプロイが静かに「消えて」しまったのか、その原因と対策の記録です。
直前コミットだけを見てスキップする、よくあるデプロイ最適化
DevPickのリポジトリは、Web向けのフロントエンドやFastAPIバックエンドだけでなく、モバイルアプリ(React Native/Expo)や運用ドキュメント、GitHub Actionsのワークフロー定義などが1つのリポジトリに共存する構成になっています。
以前、自動レビューを一番前に置いてCI全体の待ち時間を短縮した話でも触れましたが、個人開発ではCIの実行時間やVPSの負荷をいかに無駄遣いしないかが日々の快適さに直結します。
Web版のコードを一切触っていないコミット(たとえばREADMEの誤字脱字修正やドキュメントの更新、モバイルアプリ固有の修正など)のたびに、VPS上でコンテナを再ビルドしてサービスを再起動するのは明確にリソースの無駄です。そこで私は、デプロイワークフローの中に「デプロイ不要なら早期にスキップする」判定ステップを入れていました。
具体的な判定条件はシンプルで、PRに no-deploy ラベルが付与されているか、あるいはコミットに含まれる変更ファイルがデプロイ対象外パターン(docs/、*.md、mobile/ など)だけに収まっているかどうかです。
# デプロイスキップの初期実装イメージ
- name: Check deploy skip
id: check
run: |
# 直前のコミット単体(HEAD~1..HEAD)の差分を確認
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD)
# docsやmobileのみの変更ならスキップフラグを立てる
if ! grep -qvE '^(docs/|\.md$|mobile/)' <<< "$CHANGED_FILES"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
このステップの出力が skip=true であれば、後続のデプロイジョブは if: steps.check.outputs.skip != 'true' によって何事もなくスキップされます。
ローカルや単発のPRで動作確認している限り、ドキュメント修正のコミットでは綺麗にデプロイが飛ばされ、Web側の変更が入ったときだけしっかりデプロイされる。「これでお財布にも優しく効率的なCIができた」と、そのときは完全に満足していました。
IT女子 アラ美追い越された先行コミットが本番に出ない「見落とし事故」
穴が開いたのは、「PRを連続でテンポよくマージしたとき」でした。
GitHub Actionsでは、mainブランチへのプッシュが短時間で連続した場合、同一環境へのデプロイが二重に走って不整合を起こさないよう、実行制御を入れるのが一般的です。DevPickでも、古いデプロイジョブの途中で新しいマージが来た場合は、先行ジョブを「Superseded by a newer deployment(後続のデプロイに追い越されたため中止)」として安全にキャンセルする仕組みを入れていました。
しかし、ここに前述の「直前コミット単体の差分チェック」が組み合わさることで、最悪の噛み合わせが発生したのです。
実際に起きたタイムラインは次のような流れでした。
- コミットA(Web修正)をマージ:Webバックエンドの修正を含むPRをmainへマージ。デプロイジョブが起動し、コンテナビルドを開始。
- コミットB(ドキュメント修正)を直後にマージ:続けて、READMEの更新(
no-deploy付き)をマージ。 - コミットAがキャンセルされる:コミットBがmainに入ったことで、進行中だったコミットAのデプロイジョブが「後続のマージがあるから、次のジョブに任せよう」と判断して途中で中止(superseded)になる。
- コミットBのスキップ判定が走る:コミットBのワークフローが起動し、デプロイスキップ判定が実行される。
- コミットB単体を見てデプロイをスキップ:コミットBの判定スクリプトは、直前コミット(コミットAからBへの差分)だけを見るため、「今回はドキュメント変更だけだな、よしデプロイ不要!」と判断してデプロイをスキップ。
この結果どうなったか。
コミットAのデプロイは後続に譲って中止され、後続のコミットBは「自分自身にはデプロイ対象がない」としてスキップしました。つまり、コミットAに含まれていたWebの修正変更は、誰にもデプロイされないまま本番から取り残されてしまったのです。
恐ろしいのは、GitHub Actionsの画面上ではコミットBのワークフローが「正常終了(緑のチェックマーク)」として記録される点です。エラーが発生してジョブが赤くなったわけではないため、Discordやメールの失敗通知も一切飛びません。
「さっきマージした機能、ちゃんと動いてるかな?」と本番環境をブラウザで開いてみて初めて、「あれ、コードが一切反映されていない……?」と青ざめることになりました。



直前単体ではなく「本番反映済みコミットからの累積差分」を検査する
この問題の本質は、「デプロイスキップの判定基準を、直前コミット単体の差分に頼っていたこと」にあります。
本番デプロイにおいて本当に検証すべきなのは、「このPR単体にデプロイ対象があるか」ではありません。「本番環境に最後にデプロイが成功してから現在までの間に、未反映のデプロイ対象変更が1つでも残っているか」であるはずです。
そこで私は、単なる git diff HEAD~1 を完全に廃止し、本番のデプロイ履歴を遡って判定するスクリプト(check-deploy-skip.sh)を作成してワークフローに組み込みました。
実装した判定ロジックの流れは以下の3ステップです。
本番の最新デプロイ成功コミットを特定する
まず、GitHub CLI(gh api)を用いてmainブランチのコミット履歴を最新から過去へ遡り、デプロイジョブのCheck Runステータスを調べます。
直前のジョブが「後続に追い越されて中止(superseded)」になっていたり、スキップされていたりしたコミットは無視し、「実際に本番デプロイが成功完了(completed:success)した最新のコミットSHA」を見つけ出します。
成功コミットから現在までの「累積差分」を取得する
最新の成功コミットが見つかったら、GitHubのCompare APIを使って、その成功コミットから今回のコミット(CURRENT_SHA)までの未反映ファイル一覧(累積差分)を一括取得します。
# 1. 本番へ最後に反映されたコミット(LAST_DEPLOYED_SHA)からの累積差分を取得
compare_json=$(gh api "repos/$REPO/compare/${LAST_DEPLOYED_SHA}...${CURRENT_SHA}")
unreflected_files=$(jq -r '.files[]?.filename // empty' <<< "$compare_json")
# 2. 未反映差分の中にデプロイ対象ファイル(docsやmobile以外)があるか確認
NON_DEPLOY_PATTERN='^(docs/|\.md$|mobile/)'
deployable_files=$(grep -vE "$NON_DEPLOY_PATTERN" <<< "$unreflected_files" || true)
if [[ -n "$deployable_files" ]]; then
echo "未反映のデプロイ対象変更が残っているためデプロイを実行します"
exit 0 # skip=false
fi
# 3. 累積差分にもデプロイ対象変更が残っていなければ、初めて安全にスキップ
echo "skip=true" >> "$GITHUB_OUTPUT"
こうすることで、たとえ直前のWeb向けコミットAが後続コミットBに追い越されてデプロイが中止されていたとしても、コミットBの時点で「コミットAの未反映変更」が確実に検知されます。コミットB自身がドキュメント修正であっても、「未デプロイの変更が残っているからデプロイする」という正しい判断が下せるようになりました。
不確実なケースはすべて「安全側(デプロイ実行)」に倒す
さらにこの改修では、境界条件やAPI制限におけるフェイルセーフ(安全側に倒す設計)を徹底しました。
- 初回デプロイや過去履歴に見当たらない場合:成功コミットが特定できなければ、未反映放置を防ぐため強制デプロイする。
- Compare APIの上限(300ファイル)に達した場合:GitHubのCompare APIは1回で最大300ファイルまでしか返しません。上限に達している場合は、切り捨てられたファイルの中にデプロイ対象が潜んでいるリスクがあるため、安全側に倒して強制デプロイする。
- API呼び出しの失敗や権限不足(403など):スクリプトがエラーになったときに「スキップ」へ流れる(fail-open)のを防ぎ、ジョブを即座に失敗させる。
「効率化のためにスキップする」仕組みだからこそ、少しでも迷ったら「デプロイを実行する」側に倒す。この方針を徹底したことで、マージのタイミングや順序による変更の取りこぼしは完全に解消されました。



まとめ
CI/CDの実行時間短縮やリソース節約を狙った「スキップ最適化」は、個人開発・チーム開発を問わず非常に効果的な改善です。しかし、その判定条件を「直前コミット単体」という極めて局所的な情報に依存させてしまうと、予期せぬ並行処理やジョブの追い越しによって大きな穴を開けることになります。
今回のトラブルから得られた教訓は以下の3点です。
- スキップ判定は「直前」ではなく「本番反映の現在地からの累積差分」で評価する:先行ジョブが中止やスキップされた可能性を考慮し、最後に成功したコミットからの差分全体を検査する。
- 最適化ロジックは常に「安全側(デプロイ実行)」に倒す:API制限・エラー・履歴不明などの不確実な状況では、スキップではなくデプロイにフォールバックさせる。
- CIが「緑(成功)」でも変更が出ているとは限らない:スキップ処理が正常終了するとエラー通知が飛ばないため、パイプラインの振る舞い自体を疑う視点を持つ。
CIパイプラインに最適化やショートカットを組み込むときは、単体の成功ケースだけでなく「マージが重なったとき」「先行ジョブが倒れたとき」にどう振る舞うかまで見据えておくことが大切だと、改めて身に染みた一件でした。












