author: GeneralD

GitHub 自身に画像を置く道具を作りました


ロボットアームが GitHub 自身の機構(歯車の詰まったブラウザ枠)に画像を一枚そっと収めるブループリント図

GitHub のコメントレビュー本文に、画像を一枚置きたいときがあります。スクショ図生成した一枚。<img>貼れさえすればいいので、まずはどこかに画像を置いて URL を得る必要があります。

ここで、ひとつだけ譲れない線がありました。imgur のような第三者公開ホストには、絶対に上げたくない。一度公開ホストに乗った画像は、消しても消えません。索引され、キャッシュされ、手元から取り消せなくなる。だから「どこに置くか」を、URL を取りに行く前に決めておきたかった。

いちばんきれいな置き場所は、GitHub 自身だった

調べてみて腑に落ちたのは、GitHub には自前添付ストアがある、という一点でした。コメント欄に画像ドロップすると挿し込まれる、あの https://github.com/user-attachments/assets/... です。

これが心地よいのは、その画像repo の可視性に従う からです。private repo の issue や PR に貼っ添付は、その repo を見られる人にしか見えません。実際、ログインしていない状態で private repo の添付 URL を叩くと 404 が返ります。つまり user-attachments は、第三者公開ホストではない。GitHub という、もともとその中身置かれている場所の中に、置いたものが収まるだけ。譲れなかった線を、いちばん自然またぐ置き場所でした。

問題は、ここに置くための公式 API が見当たらないことでした。アップ実体POST /upload/policies/assets という経路なのですが、これは web の UI 専用で、Personal Access Token を付け叩くと 422 が返ってきます。user_session の cookie と CSRF トークン要る手元gh api からは、素直には届きません。

なら、ログイン済みのブラウザに置いてもらう

API が無いなら、UI にやってもらえばいい。すでに GitHub にログインしているブラウザを一つ用意して、そのコメント欄に画像ドロップさせる。GitHub がアップ済ませて URL を挿し込んでくれたら、その URL だけ読み取って、コメント送らずに帰る

画像が、歯車の回るブラウザを通って金庫の引き出しに収まる——ログイン済みのブラウザに置いてもらう流れの図

それだけをする小さな道具gh img という名前ghエイリアスに置きました。中身は、playwright-cli で headless の Chromium を一つ起動して、ページ開きコメント欄を見つけ画像ドロップし、挿し込まれた URL を拾っ閉じる、というだけのシェルスクリプトです。

# 一度だけ、対話的にログイン(headed なブラウザが開く)
gh img --login

# 以後はこれだけ。URL が標準出力に返る
gh img ./shot.png https://github.com/owner/repo/issues/123
gh img ./shot.png owner/repo   # その repo の新規 issue 作成画面で代用

これは前に書いた もじえもじ地続き話でもあります。あちらは「GitHub に動く文字を置く」道具で、絵文字主役でした。こちらは「GitHub に画像を置く」道具で、ファイル主役です。置きたいものが違うだけで、向いている方向同じでした。

主役は、引き算の三つ

書いてみて分かったのは、この道具難しさは「どう動かすか」ではなく「借りブラウザ手を、どう綺麗扱う」にある、という一点でした。ログイン済みのセッションは、それ自体がアカウントとほぼ等価機密です。その手借りる以上、足し算ではなく、要らないものを引いておくほうが効きます。手元では、三つ引きました。

土台から三つの部品(防壁=レンガ塀、機密=金庫、抑制=Uターンの矢印)を「−」で引いていく、引き算の三つを表す図

一つめ。防壁は一行に絞る

画像は、ロードされたページコメント欄に無条件ドロップされます。ログイン確認も、ページ制御している meta[name=user-login]読むだけ。つまり、開いてしまったページがもし悪意あるものなら、確認すり抜けローカルファイルがそのページ渡りうる。

だから防壁は一箇所しか置けません。ページ開く に、行き先ホスト検証すること。

case "$target" in
    https://github.com/*) page_url="$target" ;;
    http://*|https://*)   die "target host must be github.com: $target" 2 ;;
    */*)                  page_url="https://github.com/${target}/issues/new" ;;
    *)                    die "target must be a github.com issue/PR URL or owner/repo" 2 ;;
esac

効いているのは、受理パターン末尾にある一本のスラッシュでした。https://github.com/ まで含め一致見ると、github.com.evil.com[email protected] のような見た目似たホスト弾かれます。ドロップ自体はファイルアクセス制限管轄外なので、ここで一行ぶん丁寧ホスト見ることが、唯一守りになります。足せる防壁他にいくらでも思いつきますが、要るのはこの一行でした。

二つめ。機密は、XDG の正しい棚へ

借りセッションの cookie は、どこに置くかで意味変わります。XDG Base Directory棚分け沿って、種類ごとに置き場所分けました。

置くもの権限
ログイン cookie(アカウント相当の機密)$XDG_STATE_HOME/gh-img/userdata0700
ブラウザ選択の設定$XDG_CONFIG_HOME/playwright-cli/cli.config.json0600
一時的なスナップショット置き場$XDG_CACHE_HOME/gh-img

機密は state、設定は config、使い捨ては cache。これは仕様沿っ普遍則です。

その上で、もう一段だけ手元足し固有則がありました。cookie profile のパスcwd に依存させず固定する ことです。これは playwright の都合見えますが、「借りセッション保管場所を、実行する場所から切り離しておく」という、もう少し広い一般則の一例でもありました。

export PLAYWRIGHT_MCP_USER_DATA_DIR="${PLAYWRIGHT_MCP_USER_DATA_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/gh-img/userdata}"

playwright は persistent profile を、起動した作業ディレクトリハッシュキーにします。固定しないと、あるディレクトリ済ませログインが、スクリプトが scratch ディレクトリ移っ動くときには見えなくなる。一度動かして、ログインしたはずなのに「ログインしていない」と言われて、ようやく腑に落ち挙動でした。固定したパスは、cwd がどこであっても同じ棚指します。

三つめ。送らずに帰る、という抑制

これがいちばん地味で、いちばん要る引き算でした。URL を読み取ったら、コメント欄に書きかけ下書き破棄して、ブラウザ閉じる何も投稿しない。道具は URL を取りに来ただけだからです。

cleanup() { pw close >/dev/null 2>&1; rm -rf "$scratch/.playwright-cli"; }
trap cleanup EXIT INT TERM

trapEXIT INT TERM のどれにも掛けてあるので、ポーリング途中で Ctrl-C を押しても、ブラウザデーモンだけは閉じます。閉じるのはブラウザで、cookie profile は残す。次に呼んだときに、また --login させずに済むようにです。「閉じるけれど消さない」「読むけれど送らない」。借り手返すときの作法は、結局この抑制尽きていました。

道具はこれで全部

引いていったら、残ったのはこの一枚でした。借り手綺麗返すための仕掛けは、結局このスクリプト収まります。~/.config/gh/bin/gh-img に置いて、gh の config から img という名前呼んでいるだけです。パスはすべて $HOME$XDG_*書いてあるので、別機械にそのまま持っていっても動きます。

設計話をするなら、本当全文こそ宝です。さっきの三つの引き算は、この一枚のどこに効いているか。畳んでしまうと、読者手元同じ環境起こすための起点消えます。だから折りたたまず、ここにそのまま置きます。180 行コピペでそのまま動く一枚です。

引き算の末に残った一つの機構を分解して並べたブループリント図——道具はこれで全部
#!/usr/bin/env bash
#
# gh-img: upload an image to GitHub user-attachments and print its URL.
#
# Usage:
#   gh img <image> <issue-or-pr-url>    # upload; prints the user-attachments URL
#   gh img <image> <owner/repo>         # same, via that repo's new-issue composer
#   gh img --login [url]                # one-time interactive (headed) login
#
# Drives a persistent, authenticated headless Chromium (via playwright-cli) to
# drop the image onto a real comment composer on the given page, then reads the
# https://github.com/user-attachments/assets/<uuid> URL that GitHub inserts —
# WITHOUT submitting any comment. The asset inherits the visibility of the repo
# whose composer it is dropped on, so private-repo uploads stay auth-gated. This
# is GitHub's OWN storage, not a third-party public host.
#
# Why a real browser and not raw HTTP: the upload-policy endpoint
# (POST /upload/policies/assets) is web-UI only — a PAT returns 422; it needs the
# user_session cookie + CSRF token. Letting the live composer perform the upload
# lets the browser mint/send the correct token; we just read back the URL. If an
# official upload API ever ships, migrate this to "gh api" and drop playwright.
#
# Why a config file (not the PLAYWRIGHT_MCP_BROWSER env var) selects the browser:
# the "chromium" channel keeps the persistent login profile, whereas
# PLAYWRIGHT_MCP_BROWSER=chromium resolves to chrome-for-testing, which uses a
# DIFFERENT cookie store and silently drops the login. So browser selection is
# the one setting that genuinely needs the config file; everything else is env.
#
# Requires:
#   - playwright-cli (brew install playwright-cli)
#   - a Chromium under PLAYWRIGHT_BROWSERS_PATH (playwright-cli install-browser chromium)
#   - the "gh img" alias (shipped in this repo's gh/config.yml); or call by path
# Session cookies live in a dedicated profile under XDG_STATE_HOME (see
# PLAYWRIGHT_MCP_USER_DATA_DIR below); they are account-equivalent secrets —
# never committed, 0700. Run "gh img --login" once per machine. Single-run tool:
# a fixed session/scratch means do not invoke it concurrently with itself (a
# second run's close would kill the first's browser).

set -u
set -o pipefail

session="ghimg"

# Keep playwright XDG-clean even when invoked from a context lacking the env vars.
export PLAYWRIGHT_DAEMON_SESSION_DIR="${PLAYWRIGHT_DAEMON_SESSION_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/playwright-cli}"
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/ms-playwright}"
# Pin the login cookie store to a fixed, cwd-independent path (a dedicated gh-img
# profile). Without this, playwright keys the persistent profile by the workspace
# (cwd) hash, so a login done from one directory would be invisible when the
# script later runs from its scratch dir. STATE tier; account-equivalent secret.
export PLAYWRIGHT_MCP_USER_DATA_DIR="${PLAYWRIGHT_MCP_USER_DATA_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/gh-img/userdata}"
mkdir -p "$PLAYWRIGHT_MCP_USER_DATA_DIR" 2>/dev/null && chmod 700 "$PLAYWRIGHT_MCP_USER_DATA_DIR" 2>/dev/null

# Browser-selection config — kept in XDG_CONFIG_HOME (never $HOME), self-created
# so the script is portable to a fresh machine. Referenced via --config because
# its name isn't the cwd-walk default (.playwright/cli.config.json).
pw_config="${XDG_CONFIG_HOME:-$HOME/.config}/playwright-cli/cli.config.json"
# Contain playwright's per-call ".playwright-cli/" snapshot dir so it never lands
# in the caller's cwd (e.g. a git repo). Cache dir, outside any repo.
scratch="${XDG_CACHE_HOME:-$HOME/.cache}/gh-img"

pw() { playwright-cli -s="$session" "$@"; }
die() { printf 'gh-img: %s\n' "$1" >&2; exit "${2:-1}"; }
# Read one scalar value from a --raw eval (value is always the last output line).
pweval() { pw --raw eval "$1" 2>/dev/null | tail -1; }
# Closes the browser (keeps the persistent cookie profile) and clears snapshots.
# Wired to EXIT/INT/TERM so a Ctrl-C mid-poll never leaks the browser daemon.
cleanup() { pw close >/dev/null 2>&1; rm -rf "$scratch/.playwright-cli"; }

# GitHub has two composer shapes and no single attribute covers both:
#  1. The new-issue React body editor advertises "Markdown" via its aria-label
#     ("Markdown value") or placeholder ("Use Markdown to format your comment").
#  2. The classic issue/PR *comment* box (conversation pages, incl. merged/closed
#     PRs) advertises Markdown NOWHERE — its placeholder is a bare " " — but it
#     carries the stable id="new_comment_field". The owner/repo route only ever
#     hits shape 1, so shape 2 went unnoticed until an image was dropped onto a
#     PR/issue URL directly (then: "no comment composer found", exit 4).
# Match on either signal. The drop CSS selector below mirrors this exact set with
# the same first-in-DOM-order pick, so drop and poll always target the SAME
# element (the hidden survey "feedback" and inline-diff "comment[body]" textareas
# advertise none of these signals and are excluded).
finder="[...document.querySelectorAll('textarea')].find(x => /Markdown/i.test(x.placeholder||'') || /Markdown/i.test(x.getAttribute('aria-label')||'') || x.id === 'new_comment_field')"

command -v playwright-cli >/dev/null 2>&1 || die "playwright-cli not found (brew install playwright-cli)" 127

ensure_config() {
    [ -f "$pw_config" ] && return 0
    mkdir -p "$(dirname "$pw_config")" || return 1
    printf '%s\n' '{ "browser": { "browserName": "chromium", "launchOptions": { "channel": "chromium", "headless": true } } }' > "$pw_config"
    chmod 600 "$pw_config"
}

# --- login mode -------------------------------------------------------------
if [ "${1:-}" = "--login" ]; then
    url="${2:-https://github.com/login}"
    ensure_config || die "could not write $pw_config" 1
    mkdir -p "$scratch"; cd "$scratch" || die "cannot enter scratch dir $scratch" 1
    trap cleanup EXIT INT TERM
    pw open --persistent --headed --config "$pw_config" "$url" || die "could not open browser" 1
    printf 'gh-img: a browser window is open. Log in to GitHub (incl. 2FA), then press Enter here... ' >&2
    read -r _
    printf 'gh-img: session saved.\n' >&2
    exit 0
fi

# --- argument parsing -------------------------------------------------------
image="${1:-}"
target="${2:-}"
{ [ -n "$image" ] && [ -n "$target" ]; } || die "usage: gh img <image> <issue-or-pr-url>  |  gh img --login" 2
[ -f "$image" ] || die "image not found: $image" 2

# resolve absolute image path (relative to caller's cwd, BEFORE we cd to scratch)
img_dir=$(cd "$(dirname "$image")" 2>/dev/null && pwd) || die "cannot resolve directory of: $image" 2
abs_image="$img_dir/$(basename "$image")"

# normalize target: a github.com URL as-is, or owner/repo -> that repo's new-issue
# page. Reject every non-github host: the image is dropped onto whatever page
# loads, and the auth check only reads a page-controlled meta[name=user-login], so
# an untrusted URL carrying that meta + a "Markdown" textarea could receive the
# local file via the drop. Uploads are only meaningful on github.com
# (user-attachments), so gate on host BEFORE opening the page. The leading-slash
# in the accept pattern also rejects look-alikes (github.com.evil.com, [email protected]).
case "$target" in
    https://github.com/*) page_url="$target" ;;
    http://*|https://*)   die "target host must be github.com: $target" 2 ;;
    */*)                  page_url="https://github.com/${target}/issues/new" ;;
    *)                    die "target must be a github.com issue/PR URL or owner/repo: $target" 2 ;;
esac

ensure_config || die "could not write $pw_config" 1
mkdir -p "$scratch"; cd "$scratch" || die "cannot enter scratch dir $scratch" 1
trap cleanup EXIT INT TERM

# --- open + auth check (retry: a slow load must not read as logged-out) ------
pw open --persistent --config "$pw_config" "$page_url" >/dev/null 2>&1 || die "could not open $page_url" 1

login=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
    login=$(pweval "() => (document.querySelector('meta[name=user-login]') || {}).content || ''" | tr -d '"')
    [ -n "$login" ] && break
    sleep 1
done
[ -n "$login" ] || die "not logged in (or session expired) — run: gh img --login" 3

# --- wait for the composer to hydrate (React) -------------------------------
ready=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
    [ "$(pweval "() => ($finder) ? '1' : ''" | tr -d '"')" = "1" ] && { ready=1; break; }
    sleep 1
done
[ -n "$ready" ] || die "no comment composer found on $page_url" 4

# --- drop the image onto the composer (selector mirrors $finder: Markdown
#     placeholder/aria-label OR id="new_comment_field", first-in-DOM-order) ---
# Note: "drop --path" reads the local file in playwright-cli's own Node process
# and hands it to the page as a synthesized DataTransfer — it is NOT gated by the
# allowUnrestrictedFileAccess config flag (that flag gates the @playwright/mcp
# server's browser_file_upload tool, a different mechanism). So $abs_image may
# live anywhere the caller can read, including outside this script's scratch cwd —
# which is exactly why the github.com host gate above (not a workspace boundary)
# is what protects against an untrusted target page receiving the file.
pw drop 'textarea[placeholder*="Markdown" i], textarea[aria-label*="Markdown" i], textarea#new_comment_field' --path "$abs_image" >/dev/null 2>&1 || die "drop onto composer failed" 5

# --- poll the composer for the inserted user-attachments URL ----------------
# Rely on the timeout, not a substring heuristic: GitHub shows an
# "![Uploading <filename>…]()" placeholder mid-upload, so matching on words like
# "failed" would misfire on a file literally named e.g. build-failed.png.
url=""
for _ in {1..20}; do
    val=$(pweval "() => { const t = $finder; return t ? t.value : ''; }")
    match=$(printf '%s' "$val" | grep -oiE 'https://github\.com/user-attachments/assets/[0-9a-f-]+' | head -1)
    [ -n "$match" ] && { url="$match"; break; }
    sleep 2
done

# Discard the unsent draft text; the EXIT trap then closes the browser.
pw --raw eval "() => { const t = $finder; if (t) { const s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; s.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); } }" >/dev/null 2>&1

[ -n "$url" ] || die "upload did not return a user-attachments URL (timed out or rejected)" 6
printf '%s\n' "$url"

三つの引き算は、この一枚に散らばっています。一つめホスト検証case "$target" inブロック二つめ機密置き場所冒頭export PLAYWRIGHT_* 三行三つめ送らずに帰るは、末尾cleanup下書き破棄の処理です。残りは、コメント欄が二種類ある都合吸収する finder と、React のハイドレート待つリトライです。

ブラウザの選び方だけは、設定ファイルが要る

一つだけ、環境変数では効かない設定があります。ブラウザ種類選び方です。channel: "chromium"設定ファイル渡すと、persistent なログインプロファイルをそのまま使います。ところが PLAYWRIGHT_MCP_BROWSER=chromium という環境変数渡すと、chrome-for-testing に解決され、別の cookie store掴んで、ログイン静か落ちます。だからこの一点だけは、設定ファイルでなければ効きません。

幸いスクリプトensure_config が、無けれ最小設定自動書きます。だから厳密には、手置かなくても動きます。とはいえ手元では、viewport や timeouts も効かせたかったので、少し厚めのものを ~/.config/playwright-cli/cli.config.json に置いています。効いている一行は、やはり channel: "chromium" です。

{
  "$schema": "./cli.config.schema.json",
  "browser": {
    "browserName": "chromium",
    "launchOptions": {
      "channel": "chromium",
      "headless": true
    },
    "contextOptions": {
      "viewport": { "width": 1280, "height": 800 }
    }
  },
  "timeouts": {
    "action": 10000,
    "navigation": 60000
  }
}

手元に置くまでの手順

再現要るものは、道具一つと設定一つ、そしてエイリアス一行です。順番置いていけば、手元同じ環境立ち上がります。

まず playwright-cli を入れます。

brew install playwright-cli

次に、上スクリプト全文を ~/.config/gh/bin/gh-img置いて、実行権限付けます。

mkdir -p ~/.config/gh/bin
# (上のスクリプト全文を ~/.config/gh/bin/gh-img に保存してから)
chmod +x ~/.config/gh/bin/gh-img

設定ファイルは、さっきの json を ~/.config/playwright-cli/cli.config.json置きます。置かなくても ensure_config最小版を書いてくれますが、viewport まで効かせたいなら手置くほうが早いです。

mkdir -p ~/.config/playwright-cli
# (上の cli.config.json を ~/.config/playwright-cli/cli.config.json に保存)

道具gh img という名前呼べるように、gh の config にエイリアスを一行足します。~/.config/gh/config.ymlaliases:下に、こう書きます。

aliases:
    img: '!~/.config/gh/bin/gh-img "$@"'

最後に、一度だけ対話的にログインします。headed なブラウザ開くので、GitHub にログイン(2FA も)して、ターミナルで Enter を押すだけです。

gh img --login

これで準備完了です。以後使い方は、画像行き先渡すだけ。

gh img <画像のパ> <issue または PR URL>
gh img ./shot.png owner/repo   # その repo の新規 issue 作成画面で代用

転んだときのために、終了コード意味持たせてあります。die第二引数がそのまま exit code です。

code意味
2引数不正・ファイル無し・github.com 以外のホスト
3未ログイン(またはセッション切れ)→ gh img --login
4そのページにコメント欄が見つからない
5コメント欄へのドロップに失敗
6user-attachments の URL が返らなかった(タイムアウト等)
127playwright-cli が見つからない(brew install playwright-cli

やってみて分かったこと

実装には、まだ細かいつまみがあります。コメント欄が二種類あって、新規 issue の React エディタと、会話ページ古い id="new_comment_field" のどちらにも当たる finder を書く必要があったり。ブラウザ種類環境変数ではなく設定ファイル掴まないと、別の cookie store に逃げログイン消えたり。そういう手触りは、ドキュメント読むだけでは見えてこなくて、一度動かし転んでから分かりました。

ただ、振り返っ残るのは、機能話ではありませんでした。動かしてみて分かったのは、ログイン済みのブラウザ借りる道具は、足すことよりも引くことで形決まる、という一点だった気がします。ホスト見る一行。機密正しい棚置くこと。読んだら送らずに帰ること。この三つを引いておけば、借り手綺麗返せる。

釣り合った天秤——足すより引くことで形が決まる、という振り返りを表す図

とはいえ、これは gh img という一つの道具手元がそう見えた、というだけのことです。借りセッションをどう扱うかは、扱うものによって線引き場所変わるはず。同じパターン別道具転がすときは、自分手元でもう一度、いちばん小さい形から確かめてみてもいいかもしれません。

ちなみにこのブログ自身、CSS とクライアント側の JavaScript を原則持たないという引き算立っています。道具設計も、文章置き場所も、要らないものを先引いておくと、残ったものの輪郭がはっきりする。手元では、そんなふうに見えました。