| name | gh-upload |
| description | Upload an image, GIF or video into a GitHub PR or issue body and get back a github.com/user-attachments URL. `gh` has no native command for this — this is the same endpoint the web UI's drag-and-drop uses, so the asset inherits the repo's visibility instead of sitting on a public host. Use when embedding a screenshot, before/after collage, screencast or UI proof into a PR description, an issue, or a review comment. Triggers on "put this screenshot in the PR", "attach this to the issue", "embed this video in the PR description", "upload proof to GitHub", "nahraj to do PR". |
| allowed-tools | ["Bash(gh:*)","Bash(curl:*)","Bash(ffmpeg:*)","Read"] |
| argument-hint | [file ...] |
gh-upload
gh cannot attach files. POST https://uploads.github.com/user-attachments/assets can — it is the endpoint behind drag-and-drop in the web UI. No browser, no computer use.
Assets inherit repo visibility: upload against a private repo and the URL 404s for anyone without access. That is the reason to prefer this over an external host for anything going into a GitHub body.
1. Get the numeric repo id
REPO_ID=$(gh api repos/{owner}/{repo} --jq .id)
It must be the numeric id. gh repo view --json id returns a GraphQL node id (MDEwOlJlcG9zaXRvcnkx…) — that 404s.
2. Upload
curl -s "https://uploads.github.com/user-attachments/assets?name=$NAME&content_type=$MIME&repository_id=$REPO_ID" \
-X POST \
-H "Authorization: Bearer $(gh auth token)" \
-H "Accept: application/json" \
--data-binary "@$FILE"
$NAME is the display filename shown in the body; it does not have to equal $FILE. Use it to say what the reader is looking at — login-error-before.png, not Screenshot 2026-08-18 at 14.13.12.png.
3. Embed
| Kind | Markdown |
|---|
| Image / GIF |  |
| Video | the bare URL, alone on its own line |
| Several surfaces | one ### heading + one image per surface |
 around a video renders nothing — GitHub only mounts the player for a bare URL.
Hand the finished markdown back, or write it: gh pr edit <n> --body-file body.md.
Accepted types
Verified against the live endpoint:
| Accepted (201) | Rejected (422) |
|---|
image/png image/jpeg image/gif image/webp image/svg+xml | application/pdf |
video/mp4 video/webm video/quicktime | text/plain text/markdown |
| application/zip application/json application/octet-stream |
Media only. For a PDF, log, zip or any non-media artifact the endpoint always 422s — host it elsewhere (share-file) and link it, and note in the body that that link is public.
video/mp4 and video/webm are the two GitHub reliably plays. Transcode anything else — including the .webm a Playwright/agent-browser recording produces, if broad playback matters:
ffmpeg -i in.webm -c:v libx264 -pix_fmt yuv420p out.mp4
Gotchas
name's extension must match content_type. name=shot with no extension 422s every single type; shot.png + image/png passes. The 422 body names the mismatch — read it instead of guessing.
- Percent-encode the query values.
image/svg+xml sent raw arrives as image/svg xml → 422; send image%2Fsvg%2Bxml. Spaces in name → %20.
- Bytes are not sniffed. A text file named
.png and declared image/png uploads happily, then renders broken. The extension/mime pair is the only check — getting the real file right is on you.
- The URL can 404 for a minute after upload. Propagation is not instant — a check that runs immediately after the 201 is not evidence the upload failed. Re-check before you conclude anything.
- Keep the
github.com/user-attachments/… form. It 302s to a signed S3 URL with X-Amz-Expires=300; embed the short one and the link keeps working, embed the redirect target and it dies in five minutes.
- 404 = wrong
repository_id, or the token has no push access to that repo. gh auth token must carry repo scope.
- No delete endpoint. Every upload is permanent. Look at the image before sending it — screenshots carry whatever was on screen: tokens in a devtools panel, customer names, staging data.
- Never commit proof assets to a product branch, and do not create a
.github/pr-assets directory. Living off-repo is the point.