paths-filterの取りこぼし|差分の起点は前回の成功

paths-filterの取りこぼし|差分の起点は前回の成功

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 が何を守ってくれる仕組みなのかは、次の記事で基本から整理しています。

継続的インテグレーション(CI)入門|壊れたらすぐ気づく

正しい部品が 2 つ重なると差分が消える

原因を分解すると、どちらも仕様どおりに動いている 2 つの挙動に行き着きます。

paths-filter は「push 前のコミット」と比べる

paths-filter は、main への push で起動されたとき、push される直前のコミットと比べて変わったファイルを判定します。

dorny/paths-filter の README にも、同じブランチへの push では push 前の最新コミットとの差分で判定する、と明記されています。

paths-filter/README.md at master · dorny/paths-filter

つまり 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 に置き換わると書かれています。

Control the concurrency of workflows and jobs - GitHub Docs

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 を比べる分岐になっていました。

paths-filter/src/main.ts at master · dorny/paths-filter

あわせて 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 の照合とも通じるものがあります。

GitOps入門|Gitを「本番の正解」の置き場にする運用

条件付きデプロイを組んでいるなら、一度「キャンセルされた run の変更はどこへ行くのか」を確かめておくと、緑のまま起きる取りこぼしを先回りで防げるでしょう。

以上です。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA