Engineering

gh search code のインデックスは遅延する

  • GitHub
  • gh CLI
  • DevOps
  • Renovate

gh CLIgh search code は GitHub のコード検索 API をラップしたコマンドで、Org 内の参照を一括で洗い出すときに便利です。ただし検索結果は直近のコミットが反映されない場合があり、削除や移行の最終判断に使うと事故につながります。Contents API でファイル本文を直接取得すれば、検索インデックスの状態に依存せず実体を確認できます。

# 検索ではなく、ファイル本文を直接取得して grep する
gh api repos/LabeeHive/example-repo/contents/.github/renovate.json5 \
  --jq '.content' | base64 -d | grep -F 'LabeeHive/.github:default.json5'

この記事では、Renovate プリセットの移行作業で gh search code のインデックス遅延に気づいた経緯、Contents API で本文を取得して参照残存を確認する手順、そしてこの考え方をほかの検索 API にも当てはめる視点を扱います。

Renovate プリセット移行で最後のクリーンアップに詰まった

ラビー合同会社の GitHub Organization では、Renovate の共通プリセットを LabeeHive/.github リポジトリに置いていました。

ブログRenovateでCloudflare Workers SDKのパッケージをグルーピングするCloudflare Workers SDKはモノレポで管理されていますが、Renovateのデフォルト設定ではパッケージごとに個別のPRが作られます。wrangler、@cloudflare/vite-plugin、miniflareを1つのPRにまとめるpackageRulesの設定と、monorepo presetへの提案で浮上したimmortal PRs問題を紹介します。

設定の管理を専用リポジトリに分離したくなり、LabeeHive/renovate-config を作って共通プリセットを移しました。各リポジトリの renovate.json5 を新しい参照先に書き換える移行 PR を Org 横断に投入してすべてマージし、最後に旧ファイル本体を消そうとした、というのが今回の状況です。

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

最終ステップは「旧 LabeeHive/.github/default.json5 を削除する PR を作ってマージする」だけのつもりでした。削除前に Org 内へ旧参照が残っていないことを確認できれば、安全に消せます。

gh search code で確認しようとして違和感に気づいた

最初に取った確認手段が gh search code です。GitHub のコード検索を CLI から叩けるので、Org 全体を1コマンドで横断できます。

gh search code "LabeeHive/.github:default.json5" \
  --owner LabeeHive --limit 100

ここで違和感がありました。すべてのリポジトリの renovate.json5renovate-config 側へ書き換える PR をマージし切ったはずなのに、検索結果には旧参照を含むファイルが残って表示されます。差分自体は GitHub の UI でも確認できますし、各リポジトリの main ブランチを開けば新しい参照になっています。

検索 API の結果と、リポジトリの最新コミットが一致していません。これは「ファイルがまだ書き換わっていない」のではなく、「コード検索のインデックスが最新コミットを反映していない」状態でした。

コード検索はインデックスを介する API

GitHub のコード検索ドキュメントは、コードに対する全文検索の構文と利用条件を説明していますが、インデックス更新の遅延に関する明示的な SLA はありません。それでも、コード検索が内部的にインデックスを構築する仕組みであることは、過去のリリース履歴とコミュニティの議論から追えます。

GitHub は 2020 年の GitHub Changelog で、コード検索のインデックス対象を「直近 1 年以内に活動のあったリポジトリだけ」に絞ると発表しています。インデックス対象を絞り込めるという仕様は、コード検索が実体のコミットを直接読まず、別途構築されたインデックスを参照する設計だから成立する話です。

実運用での反映までの体感時間は、GitHub Community Discussion #13516 で利用者が「push 後にコード検索で新しいコミットがヒットしない」と報告し、GitHub スタッフが「インデックスの更新には時間がかかる」と返している流れから読み取れます。今回のように複数リポジトリへ立て続けに PR をマージした直後だと、旧内容を返してくる場面に遭遇します。

この性質を知らずに gh search code の結果だけを見て「もう参照は残っていない」と判断すると、まだ旧プリセットを extends しているリポジトリがある状態で本体を削除してしまい、次の Renovate 実行で 全リポジトリの設定読み込みが失敗します。Renovate のジョブが落ちて初めて気づく、という最悪の流れになりかねません。

検索 API は探索や概観の用途には向きますが、「削除しても問題ないか」を判定する最終確認には向きません。今回はこの境界を踏み外しかけたところで違和感に救われました。

Contents API でファイル本文を実体取得する

検索インデックスに頼らない方法として採用したのが、GitHub Contents APIrenovate.json5 の本文を直接取得して grep する手法です。

gh api repos/LabeeHive/example-repo/contents/.github/renovate.json5 \
  --jq '.content' | base64 -d

Contents API はリポジトリの最新コミットを参照するため、検索インデックスのタイムラグに左右されません。レスポンスの content フィールドは base64 エンコードされているので、base64 -d でデコードします。これでファイルの本文をそのまま標準出力に流せます。

実運用では、Org の非アーカイブリポジトリを gh repo list で列挙してループを回しました。

gh repo list LabeeHive --no-archived --limit 100 \
  --json name --jq '.[].name' | while read -r repo; do
    body=$(gh api "repos/LabeeHive/${repo}/contents/.github/renovate.json5" \
      --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)
    if echo "$body" | grep -qF 'LabeeHive/.github:default.json5'; then
      echo "HIT: ${repo}"
    fi
  done

renovate.json5 が存在しないリポジトリでは Contents API が 404 を返すため、2>/dev/null で抑えています。実際に流したところ、残存ヒットはゼロでした。gh search code 側でまだ旧参照を返していたリポジトリも、本文を取得すれば新しい参照に置き換わっていることを直接確認できました。

ここまで来て、ようやく旧 default.json5 の削除 PR を作成・マージしました。

本文ベースで「動作に影響するか」を切り分ける

実体検証の結果、コード本文に旧参照は残っていませんでしたが、別のレイヤーで旧文字列が残っているリポジトリはありました。たとえば公開ドキュメントリポジトリ LabeeHive/standardsrenovate.mdsetup.md は、設定例として旧参照を引用しており、Renovate 本体が読み込むわけではありません。renovate.json5 のように Renovate が直接読むファイルだけを書き換えれば、ドキュメント側の表記は当時の事実として残しても運用には影響しません。

「どこに何の目的で文字列が登場しているか」を Contents API で本文ごと確認すると、renovate.json5 のような動作に影響する場所と、ドキュメントのような影響しない場所をはっきり切り分けられます。検索結果の件数だけ見て「まだ残っているからダメ」と判断すると、影響のない参照に振り回されることになります。

検索 API を鵜呑みにしない考え方をほかの API にも広げる

今回は GitHub のコード検索でしたが、検索ベースの API は基本的にインデックスを介しています。Slack の検索、Notion の検索、Confluence の検索なども、書き込み直後の内容が即座に反映されるとは限りません。検索結果は「だいたい正しい一覧」を素早く得るのには向きますが、「ここに何かが存在する・しない」の最終判定には不向きです。

実体検証に切り替えるかどうかの判断軸は、操作が不可逆かどうかです。探索や概観(どこを直すべきかの当たりをつける)には検索 API で十分です。一方、削除や本番デプロイ、外部公開のような不可逆な操作の直前は、本文や実体を直接取得して確認します。

GitHub であれば Contents API、Slack なら conversations.history のような時系列 API、ファイルシステムなら findgrep の組み合わせなど、対象ごとに「インデックスを介さずに実体を読む」手段があります。検索 API の便利さに乗りつつ、最終判定だけは実体に降りるという二段構えにしておけば、インデックス遅延のような落とし穴を踏まずに済みます。

Contents API ベースの確認を運用に取り込むときの注意

Contents API ベースの確認は強力ですが、Rate Limit はそれなりに食います。gh api は認証済みリクエストとして扱われるため、認証なしより上限は緩いものの、数百リポジトリを一気に回すと制限に当たります。実行前に gh api rate_limit で残量を確認しておき、必要なら sleep を挟むか、対象を --no-archived や言語フィルターで絞り込みます。

確認スクリプトは捨てコードにせず、移行プロジェクト用のリポジトリに置いておくと再利用が効きます。同じ Org で別のプリセット移行や共通設定の差し替えが起きたとき、対象パスと検索文字列を変えるだけでそのまま使えます。ラビー合同会社では、Renovate プリセットの移行・削除に限らず、共有 GitHub Actions のバージョン固定を剥がす作業など、同じ枠組みを何度か再利用しています。

gh search code を最初に叩くこと自体は否定しません。Org を横断して「だいたいここに残っていそう」を素早くつかむのには有効です。そこから一歩踏み込んで Contents API で本文を見るところまでをセットにしておけば、検索インデックスの遅延に振り回されずに済みます。