Renovateの共通プリセットを.githubリポジトリから専用のrenovate-configリポジトリに切り出した後、各リポジトリのrenovate.json5のextendsを新しい参照先に差し替える作業が残りました。
リポジトリ単位でgit cloneしてsedをかけてgit pushする、いつものシェル芸でも書けますが、対象がOrg横断に広がるとローカルが散らかります。GitHub Contents APIとGit Refs APIをgh apiで叩くなら、git cloneを1度もせずに同じ修正PRを横断的に投げられます。
# 取得 → 置換 → ブランチ作成 → PUT → PR を gh api だけで完結
RESPONSE=$(gh api "repos/$OWNER/$REPO/contents/$FILE_PATH")
OLD_SHA=$(echo "$RESPONSE" | jq -r '.sha')
# ... 置換処理 ...
gh api -X PUT "repos/$OWNER/$REPO/contents/$FILE_PATH" \
--field "message=$COMMIT_MSG" \
--field "content=$NEW_B64" \
--field "sha=$SHA_FOR_PUT" \
--field "branch=$BRANCH"
題材はRenovateプリセットの参照先差し替えですが、.github/workflows/の同期やCODEOWNERSの一括更新など、Org横断のテキスト修正に同じ設計が効きます。
clone方式を避けた3つの理由
最初に頭に浮かんだのは、シェル芸の定番であるfor repo in $(...); do git clone ...; sed -i ...; git push ...; doneパターンです。書き慣れていて速く動きますが、今回は採用していません。理由は3つです。
作業ディレクトリが残る
リポジトリの数だけ~/tmp/migrate-XXXXのようなディレクトリが残り、後片付けがついて回ります。途中で失敗したリポジトリの中途半端なclone結果がローカルに散らかると、何が成功して何が失敗したかをリモートと突き合わせる手間が増えます。
再実行時の判断材料が2系統になる
レート制限やネットワークエラーで止まったとき、ローカルの中途半端なclone結果とリモートのブランチ状態の両方を突き合わせて「どこから再開するか」を判断することになります。判断材料が2系統に増えるほど、再実行スクリプトの分岐は複雑になります。
git のローカル機能が要らない
今回の作業はテキスト1行の置換で、gitのmerge/rebase/diff-applyといった機能は使いません。Contents APIで取得 → 置換 → PUTすれば、ブラウザーで「Edit this file」して「Commit changes」したのと同じ結果になります。gh apiはgh auth loginの認証情報をそのまま使えるので、別途トークンを取り回す手間もありません。
API完結のデメリットはバイナリ操作に弱いことですが、renovate.json5のような小さなテキストファイルが対象なので問題になりません。
本体とラッパーの2層構成
スクリプトは2本に分けています。
migrate-renovate.sh— 1リポジトリを処理する本体。引数にリポジトリ名を1つだけ取るmigrate-all.sh— 対象リポジトリの配列を持ち、本体を順に呼び出すラッパー
分割すると「1リポジトリだけ手作業で試す」「失敗したリポジトリだけ再実行する」といった操作が素直に書けます。ラッパー側は失敗を握り潰さず、SUCCESSとFAILEDの配列に振り分けて最後に集計するだけのシンプルな作りです。
# migrate-all.sh の抜粋
for repo in "${REPOS[@]}"; do
if "$MIGRATE" "$repo"; then
SUCCESS+=("$repo")
else
FAILED+=("$repo")
fi
done
ラッパーにはset -uo pipefailだけを付け、set -eは外しています。1リポジトリの失敗で全体が止まると残りの状態を別途調べる羽目になるので、失敗を記録して次に進む方が運用上扱いやすいからです。本体はset -euo pipefailで素直にフェイルファストにしています。
本体スクリプトの6ステップ
migrate-renovate.shは1リポジトリにつき以下の6ステップを順に流します。
- デフォルトブランチから対象ファイルの内容とSHAを取得
- 置換後の内容を計算し、差分がなければスキップ
- デフォルトブランチのHEAD SHAを取得
- 作業ブランチを作成(既存なら作らない)
- PUT対象のSHAを決定し、Contents APIでコミット
- PRを作成(既存なら作らない)
各ステップが独立して冪等になっているので、どこで中断しても同じコマンドで残りだけが流れます。
冪等の作り込み
このスクリプトで一番手を入れたのが冪等性です。Org横断で順に処理する以上、途中で止まる前提で書いています。再実行で2重PRが生えたり、APIエラーで進めなくなったりするのは避けたいところです。
差分がなければ即終了する
最初のチェックは「そもそも置換が必要か」です。取得した内容にsedをかけ、結果が元と同じならその時点でexit 0します。
sed -E 's|[Ll]abee[Hh]ive/\.github:default\.json5|LabeeHive/renovate-config:default.json5|g' "$TMPFILE" > "$NEWFILE"
if cmp -s "$TMPFILE" "$NEWFILE"; then
echo "[$REPO] no change needed on default branch (skip)"
exit 0
fi
正規表現で[Ll]abeeと[Hh]iveの両方を許容しているのは、旧設定の中にlabeehiveという小文字表記が混ざっていたからです。手書きの設定ファイルは表記揺れを含むという前提で書いておくと、後から例外処理を足さずに済みます。
ブランチの存在をGETで確認する
ブランチ作成は、既存なら作らずに次へ進みます。POSTを2回投げると422 Unprocessable Entityが返るので、Refs APIをGETして存在確認するのが安全です。
if gh api "repos/$OWNER/$REPO/git/refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "[$REPO] branch $BRANCH already exists, skipping creation"
else
gh api -X POST "repos/$OWNER/$REPO/git/refs" \
--field "ref=refs/heads/$BRANCH" \
--field "sha=$HEAD_SHA" >/dev/null
fi
PUT対象のSHAをブランチ側から取り直す
ここが初版で踏み抜いたポイントです。Contents APIのPUTには「上書き対象ファイルの現在のSHA」を渡しますが、このSHAはそのブランチ上の現在のファイルSHAでなければ通りません。
初版ではデフォルトブランチから取得したSHA(ステップ1のOLD_SHA)をそのままPUTに渡していました。初回実行ならブランチはHEADから作られたばかりなのでSHAが一致しますが、再実行時に「ブランチには既にコミットがある」状態になると食い違って422が返ります。
対策はシンプルで、ブランチ側のファイルを毎回取り直してそのSHAをPUTに渡します。初回実行ではブランチ側のファイルがまだ存在しない(PUTで作る前)か、デフォルトブランチと同じ状態なので、フォールバックとしてSHA_FOR_PUTにOLD_SHA(ステップ1で取得済み)を入れておきます。ついでにブランチ上のファイルが既に最新ならPUT自体をスキップできます。
SHA_FOR_PUT="$OLD_SHA"
SKIP_PUT=false
if gh api "repos/$OWNER/$REPO/contents/$FILE_PATH?ref=$BRANCH" > "$BRANCH_RESP" 2>/dev/null; then
jq -r '.content' < "$BRANCH_RESP" | base64 -d > "$BRANCH_FILE"
BRANCH_SHA=$(jq -r '.sha' < "$BRANCH_RESP")
if cmp -s "$BRANCH_FILE" "$NEWFILE"; then
echo "[$REPO] file already updated on $BRANCH, skipping commit"
SKIP_PUT=true
else
SHA_FOR_PUT="$BRANCH_SHA"
fi
fi
この修正は社内レビューの段階で指摘されて入れました。1リポジトリでのテスト実行では問題が見えず、再実行シナリオを想定したコードレビューで初めて顔を出した類のバグです。
既存PRをgh pr list --headで検出する
最後にPR作成も冪等にします。gh pr list --headで同名ブランチのPRを検索し、見つかったらスキップします。
EXISTING_PR=$(gh pr list --repo "$OWNER/$REPO" --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
echo "[$REPO] PR #$EXISTING_PR already exists"
else
gh pr create --repo "$OWNER/$REPO" --base main --head "$BRANCH" ...
fi
これで「PUTは通ったがPR作成で失敗した」状態から再実行しても、PR作成だけが追加で流れます。--base mainはデフォルトブランチがmainであることを前提にしていて、masterや別名を採用しているリポジトリではgh api repos/$OWNER/$REPO --jq '.default_branch'で取り直してから渡します。
一時ファイルはテンプレートとtrapで片付ける
mktempで作った一時ファイルはtrapで確実に消します。
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/migrate.XXXXXXXXXX")
NEWFILE=$(mktemp "${TMPDIR:-/tmp}/migrate.XXXXXXXXXX")
BRANCH_FILE=$(mktemp "${TMPDIR:-/tmp}/migrate.XXXXXXXXXX")
BRANCH_RESP=$(mktemp "${TMPDIR:-/tmp}/migrate.XXXXXXXXXX")
trap 'rm -f "$TMPFILE" "$NEWFILE" "$BRANCH_FILE" "$BRANCH_RESP"' EXIT
mktempのテンプレートを明示形式(${TMPDIR:-/tmp}/migrate.XXXXXXXXXX)で書いているのは、macOSとLinuxのmktemp実装で-pオプションの扱いが食い違うためです。macOS(BSD系)のmktempには-pがなく、Linux(GNU coreutils)のような「親ディレクトリを別指定する用途」では使えません。テンプレートを直接渡す書き方ならどちらの環境でも同じように動き、TMPDIRが設定されていればそちらを、未設定なら/tmpを使い分けます。
既存ツールを採用しなかった理由
「Org横断でファイルを書き換える」用途には、GitHub code searchと組み合わせた手作業修正、RenovateのcustomManagers、あるいはgoogle/copybaraのようなコード同期ツールなど、既存の選択肢があります。今回これらを採用しなかった理由は2つです。
1つは対象が有限で、しかも「これっきりの作業」だったこと。新しいツールの学習コストを払うより、gh apiとsedで書き捨てのスクリプトを書いた方が早く終わります。汎用ツールが想定する継続運用シナリオは、今回の作業では過剰でした。
もう1つは設計の流用が効くこと。書き捨てといっても、sedの置換ルールと対象ファイルパスを差し替えるだけでCODEOWNERS配布や.editorconfig一括更新に転用できます。冪等の作り込みは「失敗時の再実行のため」であって「定常運用のため」ではないので、ツール導入の判断軸は変わりません。
走らせた結果
対象リポジトリすべてに移行PRを投げ、ほとんどはCI通過後にautomergeか手作業マージで完了、ごく一部はCI設定の都合でbypassマージしました。Renovateのonboarding PRの放置はゼロを確認しています。
gh apiのレート制限にも引っかからず、特別なリトライ機構なしで完走しました。途中で1度だけ意図的に再実行をかけて冪等の動作確認をしましたが、既存ブランチと既存PRを正しく検出してスキップしてくれました。
応用が効く作業と効かない作業
このパターンが効くのは次のような作業です。
- リポジトリ横断の設定ファイル更新(
renovate.json5、CODEOWNERS、.editorconfig) - GitHub Actionsワークフローの一括差し替え
- READMEのバッジURL更新
- パッケージ名変更に伴う
package.jsonのrepositoryフィールド書き換え
逆に向かないのは、コード本体の修正やテストの実行が必要なケースです。gh apiベースだとCIをローカルで回せないので、機械的に安全と判断できる変更に対象を絞るのが現実的です。Org規模で繰り返しの作業が出てきたら、「これはgh apiで完結するか」を最初に問うと、ツール選定の選択肢が一段広がります。