はじめに
以前、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コメント内の画像がリンク切れになります。
実際に起きた状況はこうです。
- CI/CDからRelease modeでE2Eテスト結果のスクリーンショットをIssueに投稿
- しばらく後にリリースのrevertが発生
gh-attach-assetsタグに紐づくReleaseが巻き込まれて削除- 過去の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があり、画像のドラッグ&ドロップ時に以下の処理を行います。
upload/policiesエンドポイントにfetchしてアップロードポリシーを取得- ポリシーに含まれるURLとトークンを使ってファイルをアップロード
user-attachmentsURLが返される
Direct modeではこのフローをプログラムから再現します。
- playwright-cliでIssueページを開く
- JavaScriptでfetchをインターセプトし、
file-attachmentコンポーネントにファイルを渡す - インターセプトしたレスポンスからupload policyを取得
- 実際のXHRアップロードはブロック(トークンの消費を防ぐ)
- 取得したpolicyを使ってcurlでファイルをアップロード
- 返された
user-attachmentsURLをコメントに埋め込む
ポイントは、ブラウザ上の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を提供してくれれば不要になる仕組みですが、それまではこのアプローチで運用しています。
リンク
- GitHub: https://github.com/atani/gh-attach
- 前回の記事: gh-attach: GitHub Issue/PRに画像を自動アップロードするCLIツールを作った
- Homebrew:
brew tap atani/tap && brew install gh-attach
