How to embed images in GitHub issue bodies and comments programmatically via the CLI.
Push image files to a branch in the same repo, then reference them with a URL that works for authenticated viewers.
Step 1: Create a branch
# Get the SHA of the default branch
SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/main --jq '.object.sha')
# Create a new branch
gh api repos/{owner}/{repo}/git/refs -X POST \
-f ref="refs/heads/{username}/images" \
-f sha="$SHA"Step 2: Upload images via Contents API
# Base64-encode the image and upload
BASE64=$(base64 -i /path/to/image.png)
gh api repos/{owner}/{repo}/contents/docs/images/my-image.png \
-X PUT \
-f message="Add image" \
-f content="$BASE64" \
-f branch="{username}/images" \
--jq '.content.path'Repeat for each image. The Contents API creates a commit per file.
Step 3: Reference in markdown
Important: Use
github.com/{owner}/{repo}/raw/{branch}/{path}format, NOTraw.githubusercontent.com. Theraw.githubusercontent.comURLs return 404 for private repos. Thegithub.com/.../raw/...format works because the browser sends auth cookies when the viewer is logged in and has repo access.
Pros: Works for any repo the viewer has access to, images live in version control, no expiration. Cons: Creates commits, viewers must be authenticated, images won't render in email notifications or for users without repo access.
Upload images as files in a gist. Only works for images you're comfortable making public.
# Create a gist with a placeholder file
gh gist create --public -f description.md <<< "Image hosting gist"
# Note: gh gist edit does NOT support binary files.
# You must use the API to add binary content to gists.Limitation: Gists don't support binary file uploads via the CLI. You'd need to base64-encode and store as text, which won't render as images. Not recommended.
The most reliable way to get permanent image URLs is through the GitHub web UI:
- Open the issue/comment in a browser
- Drag-drop or paste the image into the comment editor
- GitHub generates a permanent
https://github.com/user-attachments/assets/{UUID}URL - These URLs work for anyone, even without repo access, and render in email notifications
Why the API can't do this: GitHub's
upload/policies/assetsendpoint requires a browser session (CSRF token + cookies). It returns an HTML error page when called with API tokens. There is no public API for generatinguser-attachmentsURLs.
Use puppeteer-core with local Chrome to screenshot HTML mockups:
const puppeteer = require('puppeteer-core');
const browser = await puppeteer.launch({
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 900, height: 600, deviceScaleFactor: 2 }
});
const page = await browser.newPage();
await page.setContent(htmlString);
// Screenshot specific elements
const elements = await page.$$('.section');
for (let i = 0; i < elements.length; i++) {
await elements[i].screenshot({ path: `mockup-${i + 1}.png` });
}
await browser.close();Note: MCP Playwright may not connect to localhost due to network isolation. Use puppeteer-core with a local Chrome installation instead.
| Method | Private repos | Permanent | No auth needed | API-only |
|---|---|---|---|---|
Contents API + github.com/raw/ |
✅ | ✅ | ❌ | ✅ |
Browser drag-drop (user-attachments) |
✅ | ✅ | ✅ | ❌ |
raw.githubusercontent.com |
❌ (404) | ✅ | ❌ | ✅ |
| Gist | Public only | ✅ | ✅ | ❌ (no binary) |
raw.githubusercontent.comreturns 404 for private repos even with a valid token in the URL. GitHub's CDN does not pass auth headers through.- API download URLs are temporary. URLs returned by
gh api repos/.../contents/...withdownload_urlinclude a token that expires. upload/policies/assetsrequires a browser session. Do not attempt to call this endpoint from the CLI.- Base64 encoding for large files can hit API payload limits. The Contents API has a ~100MB file size limit but practical limits are lower for base64-encoded payloads.
- Email notifications will not render images that require authentication. If email readability matters, use the browser upload method.