AI(Tips)
PR

gr2m/create-or-update-pull-request-action から gh CLI へ移行する時に踏む落とし穴 7選

saratogax
記事内に商品プロモーションを含む場合があります

以前、GitHub Actions の PR 作成を gh CLI に移行した話を投稿しました。

移行自体は数行の置き換えで済むように見えて、実はセルフホストランナーや git の内部挙動に関わる細かい落とし穴がたくさん潜んでいます。

本記事では、実際に gr2m/create-or-update-pull-request-action から gh pr create/edit へ移行する中で踏んだ 7 つの落とし穴 と、それぞれの解決方法を紹介します。

うち何件かは、AI コードレビューツール Codex に指摘してもらって初めて気付いたものです。

「自分で書いて自分でレビューする」ことの限界を感じたので、その話も最後に書きます。

背景:なぜ gh CLI へ移行するのか

gr2m/create-or-update-pull-request-action は PR を「なければ作成、あれば更新」できる便利な action ですが、現在は メンテナンスが停止しており Node.js 20 ベースのままです。

2026 年 6 月には Node.js 24 が強制化されるため、移行は待ったなしの状況でした。

代替として peter-evans/create-pull-request という選択肢もありますが、私たちのプロジェクトでは gh pr create / gh pr edit を直接呼ぶ方針を採りました。

external action への依存を減らせる、挙動が透明で追いやすい、というメリットがあります。

落とし穴 1:gh pr list –head は前方一致

gr2m と同等の「既存 PR があれば更新、なければ作成」ロジックを gh で書くと、こう書きたくなります。

gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number'

ところが、これには罠があります。

gh pr list --head は内部的に GitHub Search API の head: qualifier を使っていて、完全一致ではなく前方一致なのです。

実際に試してみました。

gh pr list --head "feature/replace-gr2m"

この検索で、別ブランチの feature/replace-gr2m-with-gh-cli が引っかかってしまいます。

つまり、誰かが feature/release-hoge-xxx のような名前で PR を開いていると、feature/release-hoge のつもりで取得した PR が別物になってしまうのです。

解決策は --jsonheadRefName を含めて、jq で完全一致フィルタを噛ませることです。

gh pr list --head "$BRANCH" --state open --json number,headRefName,headRepositoryOwner --jq "[.[] | select(.headRefName == \"$BRANCH\")] | .[0].number // empty"

ちなみに gr2m の実装を覗いてみたところ、同じ head: qualifier を使っていて同じ前方一致の問題を抱えていました。

コメントには Assuming there is only one PR for given branch と書かれており、運用上偶然問題にならなかっただけという状態だったようです。

落とし穴 2:git push は remote-tracking ref を更新しない

次のようなステップ構成を考えます。

  1. Find and Replace でマニフェストを書き換える
  2. commit して git push origin $BRANCH
  3. 「ブランチが master と差分を持つか」判定して PR 作成/更新

3 の判定で、うっかり次のように書いてしまうと誤動作します。

git diff --quiet "origin/master...origin/$BRANCH"

理由は、git push はリモートの実体を更新するだけで、ローカルの refs/remotes/origin/$BRANCH(remote-tracking ref)を更新しないからです。

remote-tracking ref は git fetch でしか進みません。

そのため push 直後に origin/$BRANCH を見ても、それは push 前の状態を指していて、正しい判定ができません。

判定は HEAD 基準にするのが正解です。

git diff --quiet "origin/master...HEAD"

落とし穴 3:セルフホストランナーの stale state

GitHub ホストランナー(ubuntu-latest など)はジョブごとに完全にクリーンな環境ですが、セルフホストランナーはワークスペースがジョブ間で再利用されることがあります。

これが何を引き起こすかというと、前回ジョブで作った feature/release-xxx のローカルブランチが残ったまま、次のジョブが走ってきます。そこで単純に git checkout -b すると…

fatal: A branch named 'feature/release-xxx' already exists.

エラーでワークフロー全体が止まります。

解決策は -B(大文字)です。

-b が「新規作成」、-B が「なければ作成、あれば強制リセット」です。

git checkout -B "$BRANCH" "origin/$BRANCH"

ここで重要なのは 開始点を明示することです。

単に git checkout -B "$BRANCH" だと現在の HEAD を起点にしてしまい、前のジョブの残骸に乗って作業する危険があります。

落とし穴 4:refspec 付きの git fetch は –prune が効かない

セルフホストランナーの stale state 対策として「必要なブランチだけ最新化すればいいだろう」と、こう書きたくなります。

git fetch --prune origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"

ところが、リモートで削除済みのブランチに対してこのコマンドを実行すると、こうなります。

fatal: couldn't find remote ref refs/heads/feature/release-xxx

exit code 128 で fatal エラー。

しかも古い refs/remotes/origin/$BRANCH はローカルに残ったままです。

--prune をつけていても、refspec で明示指定した ref には効きません。

その後 git show-ref でチェックすると stale な ref が生きているように見えるので、次のステップでそれを起点にしてしまい、削除済みの PR ブランチを復活させたり、古いコミットを含んだ PR を作ってしまうことがあります。

解決策はシンプルで、refspec を指定せずに全体を prune することです。

git fetch --prune origin

これなら削除済みブランチの remote-tracking ref は確実に消えます。

落とし穴 5:has-changes ゲートはリトライで壊れる

「変更があるときだけ PR を作成/更新する」という一見自然なロジックがあります。

コミットステップで has-changes という output を立てて、PR ステップを次のようにゲートするやり方です。

if: ${{ steps.commit-and-push.outputs.has-changes == 'true' }}

これが崩壊する典型パターンがあります。

  1. 1 回目の実行: Find and Replace 成功、commit、push まで完了
  2. gh pr create が GitHub API の一時的な不調で失敗
  3. 同じ payload でリトライ
  4. 2 回目: Find and Replace はすでに適用済みなので diff なし → has-changes=false
  5. PR ステップがスキップされ、push 済みの変更は PR として可視化されないまま放置

解決策は、PR ステップを has-changes でゲートするのをやめ、「ブランチが master と差分を持つか」で判定することです。

こうすれば、前回の push が成功している限り、何度リトライしても PR が作成/更新されるべきタイミングで正しく動きます。冪等性が確保されるわけです。

落とし穴 6:同一ブランチへの並行実行

同じカテゴリへのリリースが続けて行われると、ワークフローが並列で 2 本走ることがあります。この時、gh pr create は次のエラーで落ちます。

a pull request for branch "feature/release-xxx" into branch "master" already exists

もしくは git push の段階で non-fast-forward エラーになります。

最も簡単な解決策は、GitHub Actions の concurrency 機能でカテゴリ単位に直列化することです。

concurrency: { group: release-${{ github.event.client_payload.category }}, cancel-in-progress: false }

cancel-in-progress: false がポイントです。

true にすると「あとから来たジョブが先行ジョブをキャンセルする」挙動になるので、リリースの取りこぼしが起きます。

リリースワークフローでは 順番に必ず全部実行するのが正しい設計です。

落とし穴 7:ls-remote の exit code 区別

リモート側のブランチ存在確認には git ls-remote --exit-code --heads が便利ですが、次のように書くと意図と違う挙動になります。

if ! git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then skip; fi

git ls-remote の exit code は意味が分かれています。

exit code意味取るべき対応
0該当 ref が存在する処理続行
2該当 ref が存在しないskip
128通信/認証エラー失敗扱いにして再試行
※表は横スクロールできます

上のコードだと、通信エラーも「該当なし」も同じ skip 扱いになってしまい、一時的な GitHub 障害時に PR を作り損ねたのに成功扱いで終了してしまいます。

ちゃんと区別するとこうなります。

if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then :; else status=$?; if [ "$status" -eq 2 ]; then skip; else exit "$status"; fi; fi

Codex レビューで気付けたこと

上に挙げた 7 つの落とし穴のうち、1・2・4・5・6・7 は AI コードレビューツール Codex に指摘されて初めて気付いたものです。

自分で書いて自分でレビューする、というやり方には構造的な限界があります。

書いた時点での思考のフレームに縛られるので、「そもそもこの前提が間違っている」系の指摘は出てきにくいです。

別の AI にレビューさせると、こういう「前提を疑う」系の指摘が出てきやすいです。

特に git の内部挙動や GitHub API の隠れた仕様のような、書き手が無意識に「そういうものだ」と思っている部分に対して有効だと感じました。

今回のような「マイグレーション PR」では、Claude Code にコードを書かせて、Codex にレビューさせる二段構えが特に効きました。

Codex の指摘を Claude Code に伝えると、指摘の妥当性を検証(検証コードを実際に走らせて確認)したうえで、必要な修正だけを反映してくれます。

まとめ

  • gh pr list --head は前方一致。headRefName で完全一致フィルタを。
  • git push は remote-tracking ref を更新しない。判定は HEAD 基準で。
  • セルフホストランナーでは -B と開始点の明示で stale state を回避。
  • git fetch--prune は refspec 無しで使う。
  • has-changes ゲートはリトライで壊れるので、代わりに「master との差分」で判定。
  • 並行実行は concurrency で直列化。cancel-in-progress: false を忘れずに。
  • git ls-remote の exit code は 2 と 128 を必ず区別する。

どれも「知っていれば避けられる」類の罠ですが、逆に言えば書いてる時には気付きにくい罠でもあります。レビュー観点のチェックリストとして手元に残しておくと役立つはずです。

ABOUT ME
saratoga
saratoga
フリーランスエンジニア
仕事にも趣味にも IT を駆使するフリーランスエンジニア。技術的な TIPS や日々の生活の中で深堀りしてみたくなったことを備忘録として残していきます。
記事URLをコピーしました