Created
March 24, 2026 21:10
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment