pr-media
v0.1.0
Published
Upload images/GIFs into GitHub PRs from the CLI.
Readme
pr-media

Attach images, GIFs, and videos to a GitHub pull request from a script, a CI job, or an AI agent — no browser cookies, no API that doesn't exist.
GitHub has no public API for PR attachments. The endpoint the web UI uses to
mint user-attachments/assets/<uuid> URLs only accepts a browser session
cookie — a personal access token gets a flat 422. The tools that work
around this do it by stealing that cookie, which hands out a bearer
credential for your entire GitHub account. pr-media doesn't.
Quickstart
npm install -g pr-media
pr-media add screenshot.png before-after.gif --pr-url https://github.com/acme/widgets/pull/42<!-- pr-media -->

That markdown block is also what gets posted as the PR comment (or appended
to the description, with --to description). Requires gh auth login —
see Install below.
Why pr-media
- Three strategies, automatic fallback — no interactive browser? falls
back to
gh-only strategies automatically. - Zero cookies, ever — the only approach here that doesn't ask you to hand over your session credential (see Security model).
- Works interactive and headless — same command on your laptop and in a GitHub Actions runner.
- Light install — no bundled browser;
playwright-coreis optional, only needed for the CDP browser backend. - Built for AI agents —
--jsonoutput,--dry-run, and a ready-made agent skill (see Use with AI agents). - Cleans up after itself — a
cleanupcommand plus a GitHub Action that removes upload artifacts when a PR closes.
Security model
Cookie-based uploaders (gh-image-style tools that extract your browser's
user_session cookie and replay it against GitHub's internal upload
endpoint) are insecure by construction: that cookie is a bearer credential
for your entire GitHub account, not scoped to a repo or an expiry
window the way a PAT or gh token is. Extracting it means reading the
browser's cookie store and passing it around — in shell history, logs, a
temp file — exfiltration paths a scoped token never creates. And it's
brittle: GitHub can reshape session cookies any time, with none of a
documented API's compatibility guarantees.
pr-media never reads a cookie store and never calls a cookie-store API:
- Only scoped,
gh-managed tokens. Every non-interactive call goes through theghCLI (gh api,gh release,gh pr). Authentication is entirelygh's problem — pr-media never writes a token to disk or logs one. - The
browserstrategy reuses your session; it never reads it. It drives your own, already-logged-in browser (agent-browseror Chrome DevTools Protocol), stages a file on the comment composer's file input, reads back the URL GitHub inserts, clears the textarea, and never clicks Comment. - A CI guard enforces it.
security-guardfails the build if cookie-related patterns (cookie stores,context.cookies(),document.cookie) show up undersrc/. execFile, never a shell. External commands (gh,agent-browser) get their arguments as an argv array, never a shell string — no command injection via file paths, PR URLs, or repo names.
The three strategies
| Strategy | Auth | Produced URL | Privacy | When to use it |
|--------------|---------------------------------------------------|---------------------------------------------------------------|-------------------------------------------------------------------|----------------|
| browser | Your own, already-logged-in browser (agent-browser or CDP) — session never read | Canonical github.com/user-attachments/assets/<uuid>, the same URL the web UI would produce | Inherits the PR's own visibility (GitHub's attachment ACL) | Interactive/local use when you want the exact same URL a human dragging a file in would get |
| hidden-ref | gh CLI token (scoped PAT / OAuth / GITHUB_TOKEN), via the Git Data API | github.com/<owner>/<repo>/blob/<sha>/<file>?raw=true | Inherits the repo's visibility (private repo → URL needs repo access) | Default for most workflows, including CI, with no interactive browser required |
| release | gh CLI token, via a dedicated prerelease's assets | github.com/<owner>/<repo>/releases/download/... | Always public, even in a private repo — release assets have no separate ACL | CI on public repos, or anywhere you explicitly want a public, cacheable URL |
hidden-ref and release never launch a browser — they go through gh's
own authenticated API calls end to end.
Use with AI agents
pr-media ships an agent skill that teaches an AI
coding agent which strategy to pick and how to read --json output:
npx degit MatteoSchifano/gh-pr-media/skills/pr-media ~/.claude/skills/pr-mediaAgents should default to --json for parseable output, and can combine it
with --dry-run to preview a plan before touching anything:
$ pr-media add diff.png --pr-url https://github.com/acme/widgets/pull/42 --json
[
{
"name": "diff.png",
"url": "https://github.com/acme/widgets/blob/8f2a1c9/diff.png?raw=true",
"markdown": "",
"strategy": "hidden-ref"
}
]Install
npm install -g pr-media
# or, without installing:
npx pr-media add ./shot.png --pr-url https://github.com/acme/widgets/pull/42Requirements
gh, authenticated (gh auth login). Every strategy exceptbrowserrelies onghfor authentication — pr-media never reads or stores a token itself.- For
browserspecifically, eitheragent-browseronPATHwith its own logged-in profile, or a local Chrome running with remote debugging enabled plus the optionalplaywright-coredependency:
(override the endpoint with"Google Chrome" --remote-debugging-port=9222 \ --user-data-dir="$HOME/Library/Application Support/Google/Chrome" npm install playwright-corePR_MEDIA_CDP_URLif it's nothttp://localhost:9222).
Install as a gh extension
gh extension install MatteoSchifano/gh-pr-media
gh pr-media add ./shot.png --pr-url https://github.com/acme/widgets/pull/42gh extensions are just a repo with an executable matching the repo name —
this repo ships gh-pr-media, which execs the built
dist/cli.js (falling back to npx pr-media if the extension checkout
hasn't been built).
Usage
# Upload one or more files, auto-selecting a strategy, and post a new PR comment.
pr-media add screenshot.png demo.gif --pr-url https://github.com/acme/widgets/pull/42
# Target a PR by number + repo, or run from a checked-out branch with an open PR (no --pr needed).
pr-media add screenshot.png --pr 42 --repo acme/widgets
pr-media add screenshot.png
# Force a specific strategy instead of the auto fallback chain.
pr-media add screenshot.png --pr-url <url> --strategy hidden-ref # or release, browser
# Append to the PR description instead of posting a new comment.
pr-media add screenshot.png --pr-url <url> --to description
# Preview without uploading or touching the PR, or get machine-readable output.
pr-media add screenshot.png demo.gif --pr-url <url> --dry-run
pr-media add screenshot.png --pr-url <url> --json --to comment
# Delete the hidden upload ref (refs/uploads/pr/<N>) for a PR once you're done with it.
pr-media cleanup --pr 42 --repo acme/widgetsWith --strategy auto (the default), pr-media tries strategies in order,
falling back to the next on failure:
| Environment | Order |
|-------------------------|-------|
| Outside CI | browser → hidden-ref → release |
| In CI | hidden-ref → release → browser (no interactive browser on most runners) |
| In CI, public repo | release → hidden-ref → browser (cheap, no privacy tradeoff) |
GitHub Action: automatic cleanup
action-cleanup/ is a composite GitHub Action that
deletes the hidden-ref upload ref and the release prerelease for a PR
once it's closed, so merged/closed PRs don't leave upload artifacts behind:
# .github/workflows/pr-media-cleanup.yml
name: pr-media cleanup
on:
pull_request:
types: [closed]
permissions:
contents: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: MatteoSchifano/gh-pr-media/action-cleanup@mainBoth deletions are best-effort: a missing ref or release (404) is logged and skipped, not treated as an error.
How it works
GitHub's web UI attaches files by POSTing to
github.com/upload/policies/assets, which checks for a browser session
cookie — it isn't part of the REST or GraphQL API and rejects a PAT with
422. A deliberate anti-abuse boundary, not an oversight, and why "just
call the API" isn't an option. hidden-ref and release sidestep it
using APIs GitHub does document (Git Data API, Releases API); browser
goes through the real endpoint, but by driving an already-authenticated
browser instead of replaying its credential.
Limitations
releaseasset URLs are always public, regardless of the PR's or repo's visibility — GitHub releases have no per-asset ACL. Don't use it for private/sensitive PRs; usehidden-reforbrowserinstead.hidden-refURLs inherit the repository's visibility, not the PR's — fine for the common case, not a substitute for a finer-grained ACL.browserrequires a real, already-authenticated browser session on the machine running pr-media (agent-browser's own profile, or Chrome with remote debugging enabled and logged in once, ahead of time) — not meant for headless CI.user-attachments/assets/<uuid>is an internal, undocumented GitHub endpoint that could change shape any time. Onlybrowserdepends on it.- File size limits mirror GitHub's own web UI: 10 MB for images, 100 MB for
videos, validated (size, extension, and magic bytes) before upload.
Supported extensions:
.png,.jpg/.jpeg,.gif,.webp,.svg,.mp4,.mov,.webm.
Contributing
Issues and PRs welcome. Before opening a PR: npm ci && npm run build && npm test.
Please keep the "never touch cookies" invariant intact — CI runs a
security-guard check that fails the build if cookie-related patterns show
up under src/.
License
MIT © 2026 Matteo Schifano
