PR を続けてマージした日に限って、Web の変更だけが本番に出ていませんでした。
私が個人で運用している静的サイトで、これが 2 回起きました。
構成は、Next.js をビルドして S3 に置くサイトを GitHub Actions で main への push ごとにデプロイし、変わったパスを dorny/paths-filter で判定して、web/** が変わったときだけ Web をビルドする形です。
同時に走るデプロイは、同じグループの実行を 1 本に絞る concurrency という設定で、1 本に抑えています。
原因をたどると、どちらも単体では正しい 2 つの仕組みの組み合わせに行き着きました。
パス単位の条件付きデプロイを組んでいて、「全部緑なのに反映されていない」場面に出会ったことはありませんか?
全部緑なのに本番が古いまま
日々の作業を AI エージェントに任せているので、私のサイトでは 1 日に 6〜7 本の PR を続けてマージする日があります。
1 回目の事故では、Web の変更が翌日まで本番に出ませんでした。
2 回目はもっと大きく、4 本の PR(15 ファイル)が翌日まで反映されず、新しく追加したページが 404 のままになっていました。
やっかいだったのは、失敗の通知が 1 通も来なかったことです。
run はすべて成功かキャンセルで、Web をデプロイするジョブは「skipped(実行不要)」と表示されていました。
CI は本来、壊れたらすぐ気づくための仕組みのはずです。
ところが今回はどの run も赤くならないため、本番を開いて確かめるまで気づけません。
CI が何を守ってくれる仕組みなのかは、次の記事で基本から整理しています。
正しい部品が 2 つ重なると差分が消える
原因を分解すると、どちらも仕様どおりに動いている 2 つの挙動に行き着きます。
paths-filter は「push 前のコミット」と比べる
paths-filter は、main への push で起動されたとき、push される直前のコミットと比べて変わったファイルを判定します。
dorny/paths-filter の README にも、同じブランチへの push では push 前の最新コミットとの差分で判定する、と明記されています。
つまり 1 回の run が見るのは、その push 1 回分の差分だけです。
待機中の run は新しい run に置き換えられる
もう 1 つが、GitHub Actions の concurrency です。
私はデプロイが同時に 2 本走らないよう、グループを 1 つにして cancel-in-progress: false にしていました。
実行中の run を止めない設定なので、一見すると全部が順番に走りそうに見えます。
ところが Control the concurrency of workflows and jobs – GitHub Docs には、同じグループに待機中(pending)の run があると、キャンセルされて新しい run に置き換わると書かれています。
cancel-in-progress: true は、実行中のものまでキャンセルするための設定です。
false にしても守られるのは実行中の run だけで、待機中の run は置き換わります。
2 つが重なったときの並び
9 月 24 日には 7 本を続けてマージし、最初の 2 本の run が成功、続く 4 本が待機中にキャンセル、最後の 1 本が成功しました。
キャンセルされた 4 本の中に、Web の変更を含む PR が混ざっていました。
最後の run が見るのは最後の push の差分だけなので、最後の PR が Web を触っていなければ、Web 側の変更は誰にも判定されないまま消えてしまいます。
この日は「Web を触る PR を最後にマージする」という回避策を使っていたため、最後の run が Web をデプロイして漏れずに済みました。
回避策が無ければ、そのまま漏れていた並びです。
ただ、マージ順で守るやり方は毎回の運用ルールに頼ることになり、1 回忘れれば同じ事故が起きます。
比べる相手を「最後に成功した run」に変える
直し方の考え方はシンプルで、差分の起点を直前の push ではなく、最後に成功したデプロイ run のコミットにすることです。
キャンセルや失敗した run は「成功」に数えないので、その run が運ぶはずだった変更は、次の run の差分に自動で入ってきます。
実装は、変更を判定するジョブの最初で run の一覧から最初の成功 run のコミットを取り、paths-filter の base に渡す形です。
例えば以下のようなイメージになります(抜粋)。
- name: Resolve base (last successful run on main)
id: base
env:
GH_TOKEN: ${{ github.token }}
run: |
SHA=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow deploy.yml \
--branch main --limit 100 --json headSha,conclusion \
--jq '[.[] | select(.conclusion == "success")][0].headSha // empty' || true)
if [ -n "$SHA" ] && git cat-file -e "${SHA}^{commit}" 2>/dev/null; then
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
else
echo "sha=" >> "$GITHUB_OUTPUT" # 既定(push 前のコミット)に戻す
fi
paths-filter のソース(src/main.ts)を読むと、base にコミット SHA を渡した場合は、push 前のコミットではなくその SHA と HEAD を比べる分岐になっていました。
あわせて checkout は fetch-depth: 0 で履歴を取り、ジョブの permissions には run の一覧を読むための actions: read を足しています。
permissions:
# ...(既存の権限)
actions: read # run の一覧を読むため
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 起点のコミットまで履歴を取る
# ...(Resolve base のステップ)
- uses: dorny/paths-filter@v4
id: filter
with:
base: ${{ steps.base.outputs.sha }}
# filters: ...(省略)
修正後の本番の run では、自前のステップが出した base の行と、paths-filter の判定の行がログに次のように並びました。
base: 最後に成功した run のコミット <SHA>
Changes will be detected between <SHA> and main
この run で変わったのはワークフローの定義だけなので、infra = true・web = false という判定は正しい結果です。
全ジョブが成功で終わり、差分の起点を前回の成功 run から取れていることもログで確かめられています。
成功 run が 1 件も無い初回や、フォースプッシュで履歴に無いコミットになったときは、base を空にして従来の判定に戻す分岐にしました。
ただしこの分岐はコード上で確認しただけで、実際にはまだ一度も発生させていない未検証の状態です。
成功 run の絞り込みが古い run を返した
最初は、成功した run だけを gh run list の --status success --limit 1 で 1 件取る形にしていました。
ところが確認中に 1 回だけ、10 日前の成功 run が返ってきたのです。
直後に同じコマンドを繰り返すと、当日の最新の run が返りました。
原因は突き止めておらず、観測できたのもこの 1 回だけです。
GitHub 側の仕様として断定はできないものの、絞り込みに頼らない形へ変えました。
変更後は、status を付けずに直近 100 件を取り、jq で最初の success を選ぶ形です。
この形は、4 回続けて最新の成功 run を返しています。
もう 1 つ安心材料になったのが、誤差の向きです。
仮に古い run が返っても差分が広がって余分にデプロイするだけで、取りこぼしは起きない側に倒れます。
起点が古すぎれば無駄が増えるだけ、新しすぎれば変更が消える、という非対称があるのです。
最後に
差分検知で比べる相手は、直前のイベントではなく、最後にうまくいった適用にする。
そうしておけば、途中の実行がキャンセルされても失敗しても、未適用の変更は次の実行に必ず持ち越されます。
この考え方は CI に限らず、ファイルの同期やバックアップ、データベースのマイグレーションにもそのまま当てはまります。
「どこまで反映できたか」を状態として持ち、そこから差分を取る発想は、Git に書いた状態へ本番を合わせ続ける GitOps の照合とも通じるものがあります。
条件付きデプロイを組んでいるなら、一度「キャンセルされた run の変更はどこへ行くのか」を確かめておくと、緑のまま起きる取りこぼしを先回りで防げるでしょう。
以上です。












コメントを残す