Skip to content

Instantly share code, notes, and snippets.

@tmustier
Last active July 6, 2026 21:55
Show Gist options
  • Select an option

  • Save tmustier/eb872e756ea5c0166cfdd9ee301e7f35 to your computer and use it in GitHub Desktop.

Select an option

Save tmustier/eb872e756ea5c0166cfdd9ee301e7f35 to your computer and use it in GitHub Desktop.
GitHub Media Attachments Uploader for gh PRs/issues

GitHub Media Attachments Uploader

Upload a local screenshot/image/file to GitHub user-attachments and print either a final URL or markdown you can paste/post into a GitHub issue or pull request.

This is useful because gh can create/edit PRs/issues/comments, but it cannot upload attachment blobs itself.

Is it usable out of the box?

Mostly yes for macOS + Chrome users:

  • gh must be installed and authenticated to GitHub.
  • uv must be installed so the Python scripts can fetch their inline dependencies.
  • Chrome must be logged into github.com as a user with write access to the target repo.
  • macOS must allow Keychain access to "Chrome Safe Storage" when prompted.
  • Keep upload-github-media.py and chrome_cookie_extract.py in the same directory.

It is not fully portable out of the box for Linux/Windows or non-Chrome browsers, because cookie decryption is macOS Chrome-specific. It also relies on GitHub.com's undocumented web upload flow, so GitHub could change it.

Quick start

gh auth status
# clone/download this gist, then from the gist directory:
chmod +x upload-github-media.py chrome_cookie_extract.py

media_md=$(./upload-github-media.py --repo OWNER/REPO --file ./screenshot.png --alt "Screenshot")
cat > /tmp/github-body.md <<EOF_BODY
Short note here.

$media_md
EOF_BODY

gh pr comment 123 -R OWNER/REPO --body-file /tmp/github-body.md

You can also ask for just the URL or JSON:

./upload-github-media.py --repo OWNER/REPO --file ./screenshot.png --output url
./upload-github-media.py --repo OWNER/REPO --file ./report.pdf --output json

Pi skill install

For Pi, use SKILL.md as the skill file and place upload-github-media.py at:

github-media-attachments/scripts/upload-github-media.py

If you already have a sibling chrome-cookies skill, the uploader will use that. If not, keep chrome_cookie_extract.py adjacent to upload-github-media.py or adapt the path in load_cookie_extractor().

Security notes

  • The script prints only final URL/markdown/JSON to stdout; diagnostics go to stderr.
  • It redacts likely session tokens in error output.
  • Do not paste cookie values or raw debug output into issues/PRs.
  • Private/internal repo uploads should remain repo-access-scoped; public repo uploads are publicly readable.
name github-media-attachments
description Upload local screenshots/images/files to GitHub user-attachments using the user's logged-in Chrome session, then embed them in GitHub issues, PR bodies, or PR/issue comments via gh. Use when adding inline media or attachments to GitHub issues or pull requests from the CLI/Pi.

GitHub Media Attachments

Use this when gh needs to include a local screenshot/image/file in issue or PR markdown. Native gh can post markdown but cannot upload attachment blobs.

Upload then embed

Run the helper script from this skill directory (resolve scripts/upload-github-media.py relative to SKILL.md). For the standalone gist, run ./upload-github-media.py from the gist directory with chrome_cookie_extract.py next to it:

media_md=$(scripts/upload-github-media.py --repo OWNER/REPO --file ./screenshot.png --alt "Screenshot")
cat > /tmp/github-body.md <<EOF_BODY
Short note here.

$media_md
EOF_BODY
gh pr comment 123 -R OWNER/REPO --body-file /tmp/github-body.md

Same body file works with gh issue create, gh issue comment, gh pr create, gh pr edit, and gh issue edit.

Useful options:

scripts/upload-github-media.py --repo OWNER/REPO --file ./screenshot.png --output url
scripts/upload-github-media.py --repo OWNER/REPO --file ./report.pdf --output markdown

What the helper does

  • Uses real Chrome cookies from the sibling chrome-cookies skill; do not launch Chrome with remote debugging.
  • Gets the repo id through gh api, then uses GitHub.com's undocumented web upload flow: repo page uploadToken/upload/policies/assets → presigned S3 POST → finalize URL.
  • Prints only the final URL/markdown to stdout; diagnostics go to stderr and secrets are redacted.

Notes:

  • Requires Chrome logged into github.com as a user with repo write access; by default it matches the current gh api user login.
  • Private/internal repo uploads stay repo-access-scoped; public repo uploads are publicly readable.
  • If GitHub changes the web upload flow, fall back to opening the web UI and dragging/pasting the file.

Standalone gist note

This gist includes chrome_cookie_extract.py so the uploader can run outside Pi on macOS. If installed as a Pi skill, keep upload-github-media.py under scripts/; if using the gist directly, keep upload-github-media.py and chrome_cookie_extract.py in the same directory.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["requests>=2.32", "cryptography>=42"]
# ///
"""Upload a file to GitHub user-attachments and print URL/markdown.
Uses the user's normal Chrome session cookies plus GitHub's undocumented web
attachment flow. Keep stdout machine-readable; progress/errors go to stderr.
"""
from __future__ import annotations
import argparse
import html
import importlib.util
import json
import mimetypes
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
import requests
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("video/mp4", ".mp4")
mimetypes.add_type("video/quicktime", ".mov")
mimetypes.add_type("video/webm", ".webm")
GITHUB = "https://github.com"
def eprint(*args: object) -> None:
print(*args, file=sys.stderr)
def run_gh(args: list[str]) -> str:
proc = subprocess.run(["gh", *args], text=True, capture_output=True, check=False)
if proc.returncode:
raise RuntimeError(f"gh {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
return proc.stdout.strip()
def parse_repo(repo: str | None) -> tuple[str, str]:
if not repo:
repo = run_gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])
repo = repo.removeprefix("https://github.com/").removeprefix("github.com/").strip("/")
parts = repo.split("/")
if len(parts) != 2 or not all(parts):
raise ValueError("repo must be OWNER/REPO for github.com")
return parts[0], parts[1]
def load_cookie_extractor():
script_path = Path(__file__).resolve()
script_dir = script_path.parent
# Works both as a Pi skill (scripts/upload-github-media.py with a sibling
# chrome-cookies skill) and as this standalone gist (adjacent extractor file).
skill_dir = script_path.parents[1] if len(script_path.parents) > 1 else script_dir
candidates = [
script_dir / "chrome_cookie_extract.py",
script_dir / "extract.py",
skill_dir.parent / "chrome-cookies" / "extract.py",
Path.home() / ".pi/agent/skills/chrome-cookies/extract.py",
Path.home() / "projects/pi-setup/skills/chrome-cookies/extract.py",
]
for path in candidates:
if path.exists():
spec = importlib.util.spec_from_file_location("chrome_cookie_extract", path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
return module.extract_cookies
raise RuntimeError("Could not find chrome-cookies/extract.py")
def chrome_profiles() -> list[str]:
chrome_dir = Path.home() / "Library/Application Support/Google/Chrome"
found = []
if chrome_dir.exists():
found = [p.name for p in chrome_dir.iterdir() if (p / "Cookies").exists()]
preferred = ["Default", *[f"Profile {i}" for i in range(1, 30)]]
ordered = [p for p in preferred if p in found]
ordered.extend(sorted(p for p in found if p not in ordered))
return ordered
def choose_cookies(expected_user: str | None) -> dict[str, str]:
extract_cookies = load_cookie_extractor()
candidates: list[tuple[str, str | None, bool, dict[str, str]]] = []
for profile in chrome_profiles():
try:
cookies = extract_cookies("github.com", profile=profile, include_subdomains=True)
except Exception:
continue
if not cookies:
continue
user = cookies.get("dotcom_user")
has_session = bool(cookies.get("user_session"))
candidates.append((profile, user, has_session, cookies))
if expected_user:
for profile, user, has_session, cookies in candidates:
if has_session and user == expected_user:
eprint(f"Using Chrome profile {profile} for GitHub user {user}")
return normalize_cookies(cookies)
for profile, user, has_session, cookies in candidates:
if has_session:
eprint(f"Using Chrome profile {profile} for GitHub user {user or 'unknown'}")
return normalize_cookies(cookies)
summary = ", ".join(f"{p}:{u or '?'}:{'session' if s else 'no-session'}" for p, u, s, _ in candidates)
raise RuntimeError(f"No Chrome github.com user_session cookie found. Candidates: {summary or 'none'}")
def normalize_cookies(cookies: dict[str, str]) -> dict[str, str]:
cookies = dict(cookies)
if cookies.get("user_session") and not cookies.get("__Host-user_session_same_site"):
cookies["__Host-user_session_same_site"] = cookies["user_session"]
return cookies
def redact(text: str) -> str:
text = re.sub(r'(user_session=)[^;\s]+', r'\1<redacted>', text)
text = re.sub(
r'"(?:authenticity_token|uploadToken|asset_upload_authenticity_token|upload_authenticity_token)"\s*:\s*"[^"]+"',
'"<token>":"<redacted>"',
text,
)
text = re.sub(r"[A-Za-z0-9_+=/-]{48,}", "<redacted>", text)
return text[:1200]
def request_upload(owner: str, repo: str, repo_id: str, path: Path, expected_user: str | None) -> tuple[str, str]:
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
size = path.stat().st_size
cookies = choose_cookies(expected_user)
session = requests.Session()
for name, value in cookies.items():
session.cookies.set(name, value, domain="github.com", path="/")
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari"
repo_url = f"{GITHUB}/{owner}/{repo}"
web_headers = {"User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml"}
api_headers = {
"User-Agent": user_agent,
"Accept": "application/json",
"Origin": GITHUB,
"Referer": repo_url,
"X-Requested-With": "XMLHttpRequest",
}
page = session.get(repo_url, headers=web_headers, timeout=30)
eprint(f"GET repo page: {page.status_code}")
if page.status_code != 200:
raise RuntimeError(f"repo page failed: {page.status_code} {redact(page.text)}")
token_match = re.search(r'"uploadToken"\s*:\s*"([^"]+)"', page.text) or re.search(
r"&quot;uploadToken&quot;\s*:\s*&quot;([^&]+)&quot;", page.text
)
if not token_match:
raise RuntimeError("No uploadToken found; Chrome user may lack write access or GitHub changed the page payload")
upload_token = html.unescape(token_match.group(1))
policy_fields = {
"name": (None, path.name),
"size": (None, str(size)),
"content_type": (None, mime),
"authenticity_token": (None, upload_token),
"repository_id": (None, str(repo_id)),
}
policy = session.post(f"{GITHUB}/upload/policies/assets", headers=api_headers, files=policy_fields, timeout=30)
eprint(f"POST upload policy: {policy.status_code}")
if policy.status_code not in (200, 201):
raise RuntimeError(f"policy request failed: {policy.status_code} {redact(policy.text)}")
payload: dict[str, Any] = policy.json()
multipart = [(key, (None, str(value))) for key, value in payload["form"].items()]
with path.open("rb") as file_handle:
multipart.append(("file", (path.name, file_handle, mime)))
s3 = requests.post(
payload["upload_url"],
headers={"Origin": GITHUB, "User-Agent": user_agent},
files=multipart,
timeout=90,
)
eprint(f"POST S3 upload: {s3.status_code}")
if s3.status_code not in (200, 201, 204):
raise RuntimeError(f"S3 upload failed: {s3.status_code} {redact(s3.text)}")
finalize_fields = {"authenticity_token": (None, payload["asset_upload_authenticity_token"])}
finalize = session.put(f"{GITHUB}{payload['asset_upload_url']}", headers=api_headers, files=finalize_fields, timeout=30)
eprint(f"PUT finalize: {finalize.status_code}")
if finalize.status_code not in (200, 201):
raise RuntimeError(f"finalize failed: {finalize.status_code} {redact(finalize.text)}")
finalized = finalize.json()
href = finalized.get("href") or payload.get("asset", {}).get("href")
if not href:
raise RuntimeError(f"finalize response had no href: {redact(json.dumps(finalized))}")
return href, mime
def markdown_for(path: Path, href: str, mime: str, alt: str | None) -> str:
if mime.startswith("image/"):
label = alt or path.stem.replace("-", " ").replace("_", " ")
return f"![{label}]({href})"
return f"[{path.name}]({href})"
def main() -> int:
parser = argparse.ArgumentParser(description="Upload local media/file to GitHub user-attachments")
parser.add_argument("--repo", "-R", help="GitHub repo as OWNER/REPO; defaults to current repo")
parser.add_argument("--file", "-f", required=True, help="Local file to upload")
parser.add_argument("--alt", help="Alt text for image markdown")
parser.add_argument("--github-user", help="Expected github.com Chrome user; defaults to `gh api user` login")
parser.add_argument("--output", choices=["markdown", "url", "json"], default="markdown")
args = parser.parse_args()
path = Path(args.file).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(path)
owner, repo = parse_repo(args.repo)
expected_user = args.github_user or run_gh(["api", "user", "--jq", ".login"])
repo_id = run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".id"])
href, mime = request_upload(owner, repo, repo_id, path, expected_user)
if args.output == "url":
print(href)
elif args.output == "json":
print(json.dumps({"url": href, "markdown": markdown_for(path, href, mime, args.alt), "mime": mime}, indent=2))
else:
print(markdown_for(path, href, mime, args.alt))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
eprint(f"ERROR: {exc}")
raise SystemExit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment