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) # e.g. 169221790
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"
# 201 → {"url":"https://github.com/user-attachments/assets/<uuid>"}
$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 matchcontent_type.name=shotwith no extension 422s every single type;shot.png+image/pngpasses. The 422 body names the mismatch — read it instead of guessing.- Percent-encode the query values.
image/svg+xmlsent raw arrives asimage/svg xml→ 422; sendimage%2Fsvg%2Bxml. Spaces inname→%20. - Bytes are not sniffed. A text file named
.pngand declaredimage/pnguploads 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 withX-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 tokenmust carryreposcope. - 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-assetsdirectory. Living off-repo is the point.