---
title: "Renovateの参照差し替え先の変更をgh apiで楽をする"
description: "Renovateプリセットの参照先差し替えで、Org配下のリポジトリ群に同じ修正PRを冪等に投げるBashスクリプトを、`git clone`を一度もせずに`gh api`だけで書きました。GitHub Contents APIとGit Refs APIの組み合わせ、再実行を前提にした冪等性の4箇所、macOSとLinuxで動く`mktemp`テンプレートまでをまとめます。"
category: "DevOps"
tags: ["GitHub","gh CLI","Bash","DevOps","Renovate"]
publishedAt: "2026-06-10"
lastmod: "2026-06-10"
---

[Renovate](https://docs.renovatebot.com/)の共通プリセットを`.github`リポジトリから専用の`renovate-config`リポジトリに切り出した後、各リポジトリの`renovate.json5`の`extends`を新しい参照先に差し替える作業が残りました。

::card[https://labee.jp/posts/renovate-config-org-preset]

リポジトリ単位で`git clone`して`sed`をかけて`git push`する、いつものシェル芸でも書けますが、対象がOrg横断に広がるとローカルが散らかります。[GitHub Contents API](https://docs.github.com/en/rest/repos/contents)と[Git Refs API](https://docs.github.com/en/rest/git/refs)を[`gh api`](https://cli.github.com/manual/gh_api)で叩くなら、`git clone`を1度もせずに同じ修正PRを横断的に投げられます。

```bash
# 取得 → 置換 → ブランチ作成 → 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`の配列に振り分けて最後に集計するだけのシンプルな作りです。

```bash
# 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ステップを順に流します。

1. デフォルトブランチから対象ファイルの内容とSHAを取得
2. 置換後の内容を計算し、差分がなければスキップ
3. デフォルトブランチのHEAD SHAを取得
4. 作業ブランチを作成（既存なら作らない）
5. PUT対象のSHAを決定し、Contents APIでコミット
6. PRを作成（既存なら作らない）

各ステップが独立して冪等になっているので、どこで中断しても同じコマンドで残りだけが流れます。

## 冪等の作り込み

このスクリプトで一番手を入れたのが冪等性です。Org横断で順に処理する以上、途中で止まる前提で書いています。再実行で2重PRが生えたり、APIエラーで進めなくなったりするのは避けたいところです。

### 差分がなければ即終了する

最初のチェックは「そもそも置換が必要か」です。取得した内容に`sed`をかけ、結果が元と同じならその時点で`exit 0`します。

```bash
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して存在確認するのが安全です。

```bash
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自体をスキップできます。

```bash
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を検索し、見つかったらスキップします。

```bash
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`で確実に消します。

```bash
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](https://docs.github.com/en/search-github/github-code-search/about-github-code-search)と組み合わせた手作業修正、Renovateの`customManagers`、あるいは[google/copybara](https://github.com/google/copybara)のようなコード同期ツールなど、既存の選択肢があります。今回これらを採用しなかった理由は2つです。

1つは対象が有限で、しかも「これっきりの作業」だったこと。新しいツールの学習コストを払うより、`gh api`と`sed`で書き捨てのスクリプトを書いた方が早く終わります。汎用ツールが想定する継続運用シナリオは、今回の作業では過剰でした。

もう1つは設計の流用が効くこと。書き捨てといっても、`sed`の置換ルールと対象ファイルパスを差し替えるだけで`CODEOWNERS`配布や`.editorconfig`一括更新に転用できます。冪等の作り込みは「失敗時の再実行のため」であって「定常運用のため」ではないので、ツール導入の判断軸は変わりません。

## 走らせた結果

対象リポジトリすべてに移行PRを投げ、ほとんどはCI通過後にautomergeか手作業マージで完了、ごく一部はCI設定の都合でbypassマージしました。Renovateのonboarding PRの放置はゼロを確認しています。

`gh api`のレート制限にも引っかからず、特別なリトライ機構なしで完走しました。途中で1度だけ意図的に再実行をかけて冪等の動作確認をしましたが、既存ブランチと既存PRを正しく検出してスキップしてくれました。

::card[https://github.com/LabeeHive/renovate-config]

## 応用が効く作業と効かない作業

このパターンが効くのは次のような作業です。

- リポジトリ横断の設定ファイル更新（`renovate.json5`、`CODEOWNERS`、`.editorconfig`）
- GitHub Actionsワークフローの一括差し替え
- READMEのバッジURL更新
- パッケージ名変更に伴う`package.json`の`repository`フィールド書き換え

逆に向かないのは、コード本体の修正やテストの実行が必要なケースです。`gh api`ベースだとCIをローカルで回せないので、機械的に安全と判断できる変更に対象を絞るのが現実的です。Org規模で繰り返しの作業が出てきたら、「これは`gh api`で完結するか」を最初に問うと、ツール選定の選択肢が一段広がります。