gh-attachのRelease modeで画像が消える問題をDirect modeで解決した

ツール

はじめに

以前、GitHub Issue/PRに画像をアップロードするCLIツール gh-attach を作りました。

gh-attachにはBrowser mode(playwright-cliでブラウザ自動化)とRelease mode(GitHub Releases API利用)の2つのモードがあります。Release modeはブラウザ不要でCI環境に導入しやすいのがメリットでした。

しかし運用してみると、Release modeには致命的な問題がありました。

問題: revertでIssueの画像が消える

Release modeは画像をGitHub Releaseの成果物(asset)としてアップロードし、そのダウンロードURLをIssueコメントに埋め込みます。

https://github.com/owner/repo/releases/download/gh-attach-assets/screenshot.png

このURLはReleaseに紐づいています。つまり、Releaseの削除やタグのrevertが発生すると、成果物も一緒に消え、Issueコメント内の画像がリンク切れになります。

実際に起きた状況はこうです。

  1. CI/CDからRelease modeでE2Eテスト結果のスクリーンショットをIssueに投稿
  2. しばらく後にリリースのrevertが発生
  3. gh-attach-assets タグに紐づくReleaseが巻き込まれて削除
  4. 過去のIssueコメントに貼られた画像がすべてリンク切れに

画像URLがRelease assetに依存している以上、この問題は避けられません。

解決策: Direct mode

Web UIで画像を貼り付けると user-attachments のURLが生成されます。これはReleaseとは独立した永続的なURLです。

https://github.com/user-attachments/assets/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

このURLの生成をCLIから実現するのがDirect modeです。

仕組み

GitHubのIssueページには file-attachment というWeb Componentがあり、画像のドラッグ&ドロップ時に以下の処理を行います。

  1. upload/policies エンドポイントにfetchしてアップロードポリシーを取得
  2. ポリシーに含まれるURLとトークンを使ってファイルをアップロード
  3. user-attachments URLが返される

Direct modeではこのフローをプログラムから再現します。

  1. playwright-cliでIssueページを開く
  2. JavaScriptでfetchをインターセプトし、file-attachment コンポーネントにファイルを渡す
  3. インターセプトしたレスポンスからupload policyを取得
  4. 実際のXHRアップロードはブロック(トークンの消費を防ぐ)
  5. 取得したpolicyを使ってcurlでファイルをアップロード
  6. 返された user-attachments URLをコメントに埋め込む

ポイントは、ブラウザ上のfetchとXHRを横取りしている点です。

// fetchをインターセプトしてpolicyを取得
window.fetch = async function(input, init) {
  const url = typeof input === 'string' ? input : input.url;
  if (url.includes('upload/policies')) {
    const resp = await origFetch.apply(this, arguments);
    policyData = await resp.clone().json();
    return resp;
  }
  return origFetch.apply(this, arguments);
};

// XHRのsendをブロックしてトークン消費を防止
XMLHttpRequest.prototype.send = function(body) {
  if (this.__url && !this.__url.startsWith(window.location.origin)) return;
  return origXhrSend.apply(this, arguments);
};

policyの中身はアップロード先URL、認証トークン、フォームフィールド、HTTPヘッダーなどで、これらをcurlに渡してファイルを送信します。

使い方

設定ファイルに対象ホストを登録するだけです。該当ホストへのアップロード時に自動でDirect modeが有効になります。

# ~/.config/gh-attach/config
direct_hosts=your-ghe-host.com
gh-attach --issue 123 --image ./screenshot.png --host your-ghe-host.com --repo owner/repo

--browser オプションを付けるとDirect modeを無効にしてBrowser modeを強制できます。

Claude Codeとの開発

Direct modeの実装はClaude Codeと進めました。

fetchインターセプトのJavaScript、curlコマンドの組み立て、エラーハンドリング、設定ファイルの読み込みなど、一連の実装をClaude Codeが担当しました。

特にfetchとXHRの横取り部分は、policyを取得しつつ実際のアップロードをブロックするという繊細な制御が必要です。「fetchはインターセプトしてレスポンスを横取り、XHRのsendはブロックして」という方針を伝えたら、一発で動くコードが出てきました。

ブラウザの内部APIをハックするタイプの実装は、人間が書くと試行錯誤に時間がかかりがちですが、Claude Codeはこの手のDOM/ブラウザAPI操作に強いです。

3つのモードの使い分け

現在のgh-attachは3つのモードを持っています。

モード ブラウザ URL種別 revert耐性 用途
Browser 必要 user-attachments あり デフォルト
Release 不要 release asset なし ブラウザなし環境
Direct 必要 user-attachments あり GHE環境

Release modeは画像の永続性が不要なケース(一時的な確認用途など)では引き続き便利です。永続的に残したい画像にはBrowser modeかDirect modeを推奨します。

おわりに

Release assetのURLに画像を依存させていたのが根本原因でした。user-attachments URLを生成するDirect modeにより、revertが起きても画像が消えなくなりました。

GitHubが公式に画像アップロードAPIを提供してくれれば不要になる仕組みですが、それまではこのアプローチで運用しています。

リンク

タイトルとURLをコピーしました