gr2m/create-or-update-pull-request-action から gh CLI へ移行する時に踏む落とし穴 7選
以前、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 が別物になってしまうのです。
解決策は --json に headRefName を含めて、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 を更新しない
次のようなステップ構成を考えます。
- Find and Replace でマニフェストを書き換える
- commit して
git push origin $BRANCH - 「ブランチが 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-xxxexit 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 回目の実行: Find and Replace 成功、commit、push まで完了
gh pr createが GitHub API の一時的な不調で失敗- 同じ payload でリトライ
- 2 回目: Find and Replace はすでに適用済みなので diff なし →
has-changes=false - 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; figit 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; fiCodex レビューで気付けたこと
上に挙げた 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 を必ず区別する。
どれも「知っていれば避けられる」類の罠ですが、逆に言えば書いてる時には気付きにくい罠でもあります。レビュー観点のチェックリストとして手元に残しておくと役立つはずです。
