Skip to content

Instantly share code, notes, and snippets.

@DeBraid
Created March 24, 2026 21:10
Show Gist options
  • Select an option

  • Save DeBraid/eebf3f36de5d4996dfdf498808584a25 to your computer and use it in GitHub Desktop.

Select an option

Save DeBraid/eebf3f36de5d4996dfdf498808584a25 to your computer and use it in GitHub Desktop.
πŸ“Έ gh-upload-image β€” Upload images to GitHub PRs & comments via CLI. Works with private repos.
#!/usr/bin/env bash
# πŸ“Έ gh-upload-image β€” Upload images to GitHub for use in PR descriptions & comments
#
# Uploads images to a dedicated 'gh-assets' orphan branch via the Git Data API,
# returning a permanent URL that renders in GitHub markdown (PRs, issues, comments).
# Works with private repos by using github.com/.../raw/ URLs (GitHub's auth proxy).
#
# Prerequisites:
# - gh CLI installed and authenticated (https://cli.github.com)
# - Push access to the target repository
#
# Usage:
# gh-upload-image <image-path> [--repo OWNER/REPO]
#
# Options:
# --repo OWNER/REPO Target repo (default: current repo from git remote)
# --help, -h Show this help message
#
# Examples:
# gh-upload-image screenshot.png
# gh-upload-image /tmp/result.png --repo Shopify/world
# gh pr create --body "$(gh-upload-image screenshot.png)"
# gh pr comment 123 --body "Result: $(gh-upload-image result.png)"
#
# How it works:
# 1. Creates a 'gh-assets' orphan branch (once per repo, no shared history with main)
# 2. Uploads image as a git blob via the GitHub Git Data API
# 3. Returns a github.com/.../raw/ URL that renders inline in any markdown context
#
# Why github.com/.../raw/ instead of raw.githubusercontent.com?
# raw.githubusercontent.com requires auth for private repos and won't render.
# github.com/.../raw/ is proxied through the viewer's GitHub session, so it
# renders for anyone who has access to the repo β€” no public exposure needed.
#
# Install:
# curl -fsSL https://gist.githubusercontent.com/DeBraid/eebf3f36de5d4996dfdf498808584a25/raw/gh-upload-image -o ~/.local/bin/gh-upload-image
# chmod +x ~/.local/bin/gh-upload-image
set -euo pipefail
usage() {
grep '^#' "$0" | tail -n +2 | sed 's/^# \{0,1\}//' >&2
exit 1
}
[[ $# -lt 1 ]] && usage
IMAGE_PATH=""
REPO=""
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--help|-h) usage ;;
*) IMAGE_PATH="$1"; shift ;;
esac
done
[[ -z "$IMAGE_PATH" ]] && usage
# Resolve repo: explicit --repo > git remote > error
if [[ -z "$REPO" ]]; then
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)
if [[ -z "$REPO" ]]; then
echo "Error: Could not detect repo. Run from a git repo or pass --repo OWNER/REPO" >&2
exit 1
fi
fi
BRANCH="gh-assets"
TIMESTAMP=$(date +%s)
FILENAME="$(basename "$IMAGE_PATH")"
DEST_PATH="uploads/${TIMESTAMP}-${FILENAME}"
if [[ ! -f "$IMAGE_PATH" ]]; then
echo "Error: File not found: $IMAGE_PATH" >&2
exit 1
fi
# Validate image extension
case "${FILENAME##*.}" in
png|PNG) ;; jpg|jpeg|JPG|JPEG) ;; gif|GIF) ;; webp|WEBP) ;; svg|SVG) ;;
*) echo "Warning: '${FILENAME##*.}' may not render as an image in GitHub markdown" >&2 ;;
esac
# Verify repo access before doing anything
if ! gh api "repos/${REPO}" --jq '.full_name' &>/dev/null; then
echo "Error: Cannot access repo '${REPO}'. Check the name and your permissions." >&2
exit 1
fi
echo "Uploading to ${REPO}..." >&2
# Create orphan branch if it doesn't exist (one-time per repo)
if ! gh api "repos/${REPO}/git/ref/heads/${BRANCH}" &>/dev/null; then
echo "Creating orphan branch '${BRANCH}' in ${REPO}..." >&2
EMPTY_TREE=$(gh api "repos/${REPO}/git/trees" \
-f 'tree[][path]=README.md' \
-f 'tree[][mode]=100644' \
-f 'tree[][type]=blob' \
-f 'tree[][content]=# gh-assets\n\nOrphan branch for hosting images referenced in PRs and issues.\nManaged by `gh-upload-image`. Do not merge.' \
--jq '.sha')
COMMIT=$(gh api "repos/${REPO}/git/commits" \
-f "message=Initialize gh-assets branch" \
-f "tree=${EMPTY_TREE}" \
--jq '.sha')
gh api "repos/${REPO}/git/refs" \
-f "ref=refs/heads/${BRANCH}" \
-f "sha=${COMMIT}" > /dev/null
echo "Created orphan branch '${BRANCH}'" >&2
fi
# Upload image as a git blob
B64_CONTENT=$(base64 < "$IMAGE_PATH" | tr -d '\n')
BLOB_SHA=$(gh api "repos/${REPO}/git/blobs" \
-f "content=${B64_CONTENT}" \
-f "encoding=base64" \
--jq '.sha')
# Get current branch state
CURRENT_SHA=$(gh api "repos/${REPO}/git/ref/heads/${BRANCH}" --jq '.object.sha')
CURRENT_TREE=$(gh api "repos/${REPO}/git/commits/${CURRENT_SHA}" --jq '.tree.sha')
# Create new tree with the image added
NEW_TREE=$(gh api "repos/${REPO}/git/trees" \
-f "base_tree=${CURRENT_TREE}" \
-f "tree[][path]=${DEST_PATH}" \
-f "tree[][mode]=100644" \
-f "tree[][type]=blob" \
-f "tree[][sha]=${BLOB_SHA}" \
--jq '.sha')
# Commit and update branch ref
NEW_COMMIT=$(gh api "repos/${REPO}/git/commits" \
-f "message=Upload ${FILENAME}" \
-f "tree=${NEW_TREE}" \
-f "parents[]=${CURRENT_SHA}" \
--jq '.sha')
gh api "repos/${REPO}/git/refs/heads/${BRANCH}" \
-X PATCH \
-f "sha=${NEW_COMMIT}" > /dev/null
# Build URL using github.com/OWNER/REPO/raw/ format.
# This works for PRIVATE repos because GitHub proxies through the viewer's session.
# (raw.githubusercontent.com does NOT work for private repos β€” returns 404)
RAW_URL="https://github.com/${REPO}/raw/${BRANCH}/${DEST_PATH}"
echo "" >&2
echo "βœ… Uploaded: ${FILENAME}" >&2
echo "πŸ“Ž URL: ${RAW_URL}" >&2
echo "" >&2
# stdout: markdown image reference (for piping into gh commands)
echo "![${FILENAME}](${RAW_URL})"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment