{"id":49838651,"url":"https://github.com/enthus-appdev/gh-attach","last_synced_at":"2026-05-14T02:38:41.362Z","repository":{"id":350667289,"uuid":"1197962114","full_name":"enthus-appdev/gh-attach","owner":"enthus-appdev","description":"Upload images to GitHub PRs and issues, privately scoped to repo visibility","archived":false,"fork":false,"pushed_at":"2026-04-11T13:33:39.000Z","size":4835,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-11T14:14:17.653Z","etag":null,"topics":["gh-extension","github-api","github-cli","golang","image-upload","private-images","pull-request","screenshot"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/enthus-appdev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-01T02:37:27.000Z","updated_at":"2026-04-11T13:33:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/enthus-appdev/gh-attach","commit_stats":null,"previous_names":["enthus-appdev/gh-attach"],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/enthus-appdev/gh-attach","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enthus-appdev%2Fgh-attach","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enthus-appdev%2Fgh-attach/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enthus-appdev%2Fgh-attach/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enthus-appdev%2Fgh-attach/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/enthus-appdev","download_url":"https://codeload.github.com/enthus-appdev/gh-attach/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enthus-appdev%2Fgh-attach/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33008548,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"online","status_checked_at":"2026-05-14T02:00:06.663Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["gh-extension","github-api","github-cli","golang","image-upload","private-images","pull-request","screenshot"],"created_at":"2026-05-14T02:38:35.665Z","updated_at":"2026-05-14T02:38:41.353Z","avatar_url":"https://github.com/enthus-appdev.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# gh-attach\n\n[![Go](https://github.com/enthus-appdev/gh-attach/actions/workflows/go.yml/badge.svg)](https://github.com/enthus-appdev/gh-attach/actions/workflows/go.yml)\n[![Coverage](https://github.com/enthus-appdev/gh-attach/raw/badges/.badges/main/coverage.svg)](https://github.com/enthus-appdev/gh-attach/actions/workflows/go.yml)\n\nA [gh](https://cli.github.com/) extension for uploading images to GitHub PRs and issues, privately scoped to repo visibility.\n\nImages are pushed to an auth-protected ref under `refs/uploads/issues/\u003cN\u003e` (one per PR/issue, invisible in the Branches UI) and rendered as inline markdown via `blob/\u003ccommit-sha\u003e/\u003cfile\u003e?raw=true` URLs — written to stdout by default, optionally upserted as a PR/issue comment with `--comment`. No public URLs, no gists — image access is gated by repo visibility (private repos require an authenticated browser session to view).\n\n## Install\n\n```bash\ngh extension install enthus-appdev/gh-attach\n```\n\n## Usage\n\nBy default, `gh attach` uploads the files and prints the rendered markdown to **stdout** — it does *not* post a comment unless you ask. The caller decides what to do with the markdown: embed it in a PR body, pipe it to `gh pr comment`, paste it into Slack, or tee it to a file.\n\n```bash\n# Upload and print embeddable markdown to stdout\ngh attach 123 mockup.png\n\n# Multiple files in one upload group\ngh attach 123 before.png after.png\n\n# Glob patterns\ngh attach 123 ./images/*.png\n\n# Auto-detect PR from current branch\ngh attach screenshot.png\n\n# Label the group with --title (flags must come before the number)\ngh attach --title \"After fix\" 123 diagram.png\n\n# Also post the markdown as an upserted PR/issue comment (pre-v0.3 behavior)\ngh attach --comment 123 screenshot.png\n\n# Target a different repo (or run from outside any git clone)\ngh attach --repo enthus-appdev/gh-attach 123 screenshot.png\ngh attach --repo https://github.com/enthus-appdev/gh-attach 123 screenshot.png\n\n# Ad-hoc upload with no PR or issue (see \"Ad-hoc uploads\" below)\ngh attach --key design-v2 mockup.png\ngh attach --key docs/arch-diagram diagram.png\n\n# Emit a JSON result object instead of the markdown table (see below)\ngh attach --json 123 screenshot.png\n\n# Read file bytes from stdin with --name BASENAME (see \"Reading from stdin\" below)\nscreencapture -i -t png - | gh attach --name shot.png 123 -\n\n# Download files back to disk (see \"gh attach get\" below)\ngh attach get 123 --output ./restored\n```\n\nBy default `gh attach` reads the target repo from the current clone's `origin` remote. Pass `--repo OWNER/NAME` (or a full GitHub URL) to target a different repo or to run from outside any git clone. Whenever `--repo` is used, `NUMBER` or `--key` must be passed explicitly — PR auto-detection only works inside a clone of the target repo.\n\n### JSON output\n\nPass `--json` to get a structured result object on stdout instead of the markdown table. Stderr is suppressed in JSON mode (no progress line, no `Uploaded:` URL list) so the output is pipe-friendly:\n\n```bash\n$ gh attach --json 123 screenshot.png | jq\n{\n  \"repo\": \"owner/repo\",\n  \"target\": \"#123\",\n  \"namespace\": \"issue\",\n  \"number\": 123,\n  \"ref\": \"refs/uploads/issues/123\",\n  \"sha\": \"abc1234def5678cafe\",\n  \"files\": [\n    {\n      \"name\": \"screenshot.png\",\n      \"url\": \"https://github.com/owner/repo/blob/abc1234def5678cafe/screenshot.png?raw=true\"\n    }\n  ],\n  \"markdown\": \"| screenshot.png |\\n|---|\\n| ![screenshot.png](...) |\"\n}\n```\n\nKey-mode uploads populate `key` and `namespace: \"misc\"` instead of `number`/`\"issue\"`. With `--comment`, the JSON gains a `comment_url` field with the URL of the upserted comment. Both `number`/`key` and `comment_url` use `omitempty`, so consumers see exactly the relevant fields and nothing else.\n\nUseful for scripting:\n\n```bash\n# Upload + extract just the URL\nURL=$(gh attach --json 123 file.png | jq -r '.files[0].url')\n\n# Upload + capture the commit SHA for later reference\nSHA=$(gh attach --json --key design-v2 mockup.png | jq -r '.commit_sha')\n\n# Use the rendered markdown as-is (bypasses the `| jq -r` for CLI composition)\nMARKDOWN=$(gh attach --json 123 file.png | jq -r '.markdown')\n```\n\nOn failure, `--json` still exits 1 and writes the error to stderr as plain text — the shape is \"JSON on stdout means success, check exit code before parsing\". If `--comment` is used with `--json` and the comment post fails after a successful upload, the whole operation exits 1 with no JSON on stdout (upload succeeded on GitHub, but the consumer loses the reference in stdout — break the operation into two steps with a follow-up `gh pr comment` if partial-success handling matters).\n\n### Ad-hoc uploads (no PR or issue)\n\nNot every upload belongs to a PR or issue. Screenshots for a README, diagrams for a docs site, images for a not-yet-created issue, photos for release notes — all of these want a stable repo-scoped URL without the tracking overhead of a placeholder issue.\n\nPass `--key KEY` to upload to `refs/uploads/misc/KEY` instead of `refs/uploads/issues/\u003cN\u003e`:\n\n```bash\n# Upload a README banner\ngh attach --key readme-banner banner.png\n\n# Prepare an image for an issue you haven't created yet, then use the markdown in the issue body\nMARKDOWN=$(gh attach --key feature-mockup screenshot.png)\ngh issue create --title \"New feature\" --body \"## Design\n\n$MARKDOWN\"\n\n# Hierarchical keys are allowed — useful for organization\ngh attach --key docs/arch-diagram diagram.png\ngh attach --key releases/v1.0/hero hero.png\n```\n\n**Key rules**: 1–100 characters, letters/digits/`._-` plus `/` for subpaths, must start with a letter/digit/underscore, and cannot be purely numeric (that would collide visually with PR/issue numbers). Leading `.`, `..`, `//`, trailing `/`, and `.lock` suffix are rejected per git's ref name rules.\n\n**What's different from the PR/issue mode**:\n- No PR auto-detection — `--key` always targets the key you supply.\n- `--comment` is not allowed (there's no PR/issue to comment on).\n- The cleanup workflow (see below) does **not** touch ad-hoc refs — they're user-managed.\n\n**Manual cleanup** when you're done with an ad-hoc upload — use `gh attach delete` (see [Managing uploads](#managing-uploads) below), or drop straight to the REST API:\n\n```bash\ngh api -X DELETE repos/OWNER/NAME/git/refs/uploads/misc/KEY\n```\n\nDeleting the ref orphans the blob storage and GitHub eventually GCs it.\n\n## Managing uploads\n\nTwo in-tool commands for inspecting and removing existing upload refs. These are the ergonomic equivalents of raw `gh api` calls against `refs/uploads/*`.\n\n### `gh attach list`\n\nList every upload ref in the target repo, for both `refs/uploads/issues/*` (PR/issue-scoped) and `refs/uploads/misc/*` (ad-hoc) namespaces.\n\n```bash\ngh attach list\n```\n\nExample output:\n\n```\nTARGET           SHA        NAMESPACE\n#42              abc1234    issue\n#123             def5678    issue\nmisc/design-v2   9876abc    misc\nmisc/docs/arch   5555000    misc\n\n4 upload ref(s) in owner/repo\n```\n\n**Flags**:\n- `--repo OWNER/NAME` — target a specific repo instead of the current clone's origin\n- `--issues` — show only `refs/uploads/issues/*` refs (mutually exclusive with `--misc`)\n- `--misc` — show only `refs/uploads/misc/*` refs\n- `--json` — emit JSON instead of the text table, for scripting:\n\n```bash\n$ gh attach list --json\n[\n  {\n    \"ref\": \"refs/uploads/misc/design-v2\",\n    \"sha\": \"9876abc...\",\n    \"namespace\": \"misc\",\n    \"target\": \"misc/design-v2\",\n    \"key\": \"design-v2\"\n  }\n]\n```\n\n### `gh attach delete`\n\nDelete an upload ref. Takes either a positional `NUMBER` (to delete `refs/uploads/issues/NUMBER`) or `--key KEY` (to delete `refs/uploads/misc/KEY`), and prompts for confirmation by default.\n\n```bash\n# Delete an ad-hoc upload\ngh attach delete --key design-v2\n\n# Delete an issue/PR upload (rarely needed — the cleanup workflow handles this)\ngh attach delete 42\n\n# Skip the confirmation prompt (for scripts)\ngh attach delete --yes --key design-v2\n```\n\n**Flags**:\n- `--repo OWNER/NAME` — target a specific repo\n- `--key KEY` — ad-hoc target (mutually exclusive with positional `NUMBER`)\n- `--yes` / `-y` — skip the interactive confirmation prompt\n\nThe confirmation prompt reads from stdin, so running `gh attach delete` in a non-interactive context (CI, piped input) without `--yes` will fail with a clear error asking you to pass `--yes`.\n\nDeleting a ref that doesn't exist exits 1 with `error: refs/... not found in OWNER/NAME`. Aborting the confirmation prompt (answering `n` or just pressing enter) exits 0 with `Aborted` — it's not an error, just a no-op.\n\n### `gh attach get`\n\nDownload the files stored under an upload ref to the local disk — the exact inverse of the upload flow. Takes either a positional `NUMBER` (to fetch from `refs/uploads/issues/NUMBER`) or `--key KEY` (to fetch from `refs/uploads/misc/KEY`).\n\n```bash\n# Pull every file for PR/issue #42 into the current directory\ngh attach get 42\n\n# Pull into a specific directory (created if missing)\ngh attach get 42 --output ./restored\n\n# Pull an ad-hoc upload\ngh attach get --key design-v2 --output ./mockups\n\n# Overwrite existing files\ngh attach get 42 --force\n\n# Auto-detect PR from the current branch\ngh attach get\n\n# JSON result for scripting\ngh attach get 42 --json | jq -r '.files[].path'\n```\n\n**Flags**:\n- `--repo OWNER/NAME` — target a specific repo (NUMBER or `--key` must be explicit when `--repo` is used)\n- `--key KEY` — ad-hoc source (mutually exclusive with positional `NUMBER`)\n- `--output DIR` — target directory (default: `.`; created if missing, including intermediate parents)\n- `--force` — overwrite existing files in the output directory (default: error on conflict)\n- `--json` — emit a `downloadResult` JSON object on stdout instead of text\n\n**Pre-flight atomicity**: if any target file already exists without `--force`, `gh attach get` fails **before writing any files** and lists every conflict. You never end up with a half-populated output directory from a failed run.\n\n**Output** (text mode): written file paths go to stdout (one per line) so pipelines like `gh attach get 42 | xargs -I {} open {}` just work. A per-file line plus a summary goes to stderr so interactive users see the narrative:\n\n```\nDownloading from #42 in owner/repo...\n  shot.png → ./shot.png (850 B)\n  note.md → ./note.md (1.2 KiB)\nDownloaded 2 file(s) to .\n```\n\n**JSON mode** suppresses stderr entirely and emits a single `downloadResult` object on stdout:\n\n```bash\n$ gh attach get 42 --json | jq\n{\n  \"repo\": \"owner/repo\",\n  \"target\": \"#42\",\n  \"namespace\": \"issue\",\n  \"number\": 42,\n  \"ref\": \"refs/uploads/issues/42\",\n  \"sha\": \"abc1234def5678\",\n  \"output_dir\": \".\",\n  \"files\": [\n    {\n      \"name\": \"shot.png\",\n      \"path\": \"shot.png\",\n      \"size\": 850,\n      \"sha\": \"blob-sha-1\"\n    }\n  ]\n}\n```\n\nUse cases:\n- **Round-trip attachments** across a fresh clone (`gh attach get --key docs/hero --output ./images`)\n- **Scripted cleanup** before closing: pull the attachments locally as a backup, then `gh attach delete`\n- **Review workflow**: fetch a contributor's screenshots from a PR into a temp dir and inspect them side-by-side\n- **Migration**: read the bytes out of one repo and push them to another with the upload flow\n\n### Composing with other tools\n\nBecause the markdown goes to stdout, `gh attach` plays well with shell pipelines:\n\n```bash\n# Embed uploads directly in a PR body\nMARKDOWN=$(gh attach 123 dist/report.png)\ngh pr edit 123 --body \"Build passed.\n\n$MARKDOWN\"\n\n# Copy to clipboard for manual pasting (Wayland / X11 / macOS)\ngh attach 123 screenshot.png | wl-copy\ngh attach 123 screenshot.png | xclip -selection clipboard\ngh attach 123 screenshot.png | pbcopy\n\n# Pipe into gh-cli's own commenter instead of gh-attach's upsert\ngh attach 123 file.png | gh pr comment 123 --body-file -\n\n# Save for later\ngh attach 123 file.png \u003e upload.md\n```\n\nOn stderr you get the progress line plus one directly-embeddable URL per file, so interactive users see copy-pasteable links in their terminal even when stdout is piped:\n\n```\nUploading 1 file(s) to #123 in owner/repo...\nUploaded:\n  https://github.com/owner/repo/blob/abc1234/screenshot.png?raw=true\n```\n\n### Reading from stdin\n\nPass `-` as the single file argument (with `--name BASENAME`) to read file bytes from stdin instead of disk. Useful for tools that emit images to a pipe — screen capture, image processing, clipboard readers:\n\n```bash\n# macOS: interactive screen capture straight into an upload\nscreencapture -i -t png - | gh attach --name shot.png 123 -\n\n# Linux / Wayland: grim + slurp region capture\ngrim -g \"$(slurp)\" - | gh attach --name region.png 123 -\n\n# ImageMagick: resize on the fly before uploading\nmagick input.png -resize 50% - | gh attach --name resized.png --key docs/diagram -\n\n# macOS: upload the current clipboard image\npbpaste | gh attach --name clipboard.png 42 -\n```\n\nThe `--name` flag is **required** when reading from stdin — it supplies the basename used for the git tree entry, the embed URL, and the markdown alt text. Stdin mode works with `--key`, `--comment`, `--json`, and `--repo` the same way a disk-backed upload does.\n\n**Rules**:\n- `-` must be the *only* file argument (mixing stdin with disk files is not supported).\n- `--name` is rejected when no `-` is present.\n- `--name` must be a basename, not a path — no `/`, no `\\`, no `.` or `..`, max 255 bytes.\n- An empty stdin stream is allowed (produces a 0-byte upload), so upstream tools that pipe nothing don't cause a silent no-op.\n\n## How it works\n\n1. Reads image files from disk.\n2. Pushes them as a single fast-forwarding commit to `refs/uploads/issues/\u003cN\u003e` (default) or `refs/uploads/misc/\u003ckey\u003e` (when `--key` is used) via the GitHub Git Data API. No local checkout needed. The ref lives outside `refs/heads/*` and `refs/tags/*`, so it does not appear in the Branches UI, is not subject to branch protection / rulesets, and does not trigger `push` workflows.\n3. Renders a markdown table of inline images and writes it to stdout. Each embed URL uses the *commit SHA* directly (`blob/\u003csha\u003e/\u003cfile\u003e?raw=true`), so URLs from previous uploads remain valid as long as the ref is alive — fast-forwarding adds new commits without invalidating prior ones.\n4. With `--comment`, also posts or updates a PR/issue comment carrying the same markdown, tracked via an HTML-comment marker so repeated calls upsert into a single comment instead of piling up. Not available in ad-hoc (`--key`) mode.\n5. Images are accessible only to users who can access the repo. On private repos, the embed URL requires a browser session cookie (not even API PATs work against the embed URL — only the parallel `api.github.com/.../contents/{path}?ref={sha}` endpoint accepts tokens).\n\n## Cleanup\n\n### PR/issue uploads — automatic\n\nTo automatically remove uploaded images when a PR or issue is closed, copy [`.github/workflows/cleanup-gh-attach.yml`](.github/workflows/cleanup-gh-attach.yml) from this repo into your repo's `.github/workflows/` directory. The same file is installed in this repo as the canonical source — re-sync from `main` whenever you want to pick up improvements (the copy is a snapshot, not a live link).\n\nNo customization required — the workflow uses `github.repository` and the closed event's number to find and delete the upload ref. It listens to both `pull_request: closed` and `issues: closed`, so it covers issue uploads as well as PR uploads. The \"no upload ref for this PR\" case is handled silently (no error if you didn't post any images on that PR).\n\nThe workflow only touches `refs/uploads/issues/\u003cN\u003e` — ad-hoc (`refs/uploads/misc/\u003ckey\u003e`) refs are **not** affected.\n\n### Ad-hoc (`--key`) uploads — manual\n\nBecause ad-hoc uploads have no close event to hook, you manage their lifetime yourself. One `gh api` call per ref:\n\n```bash\ngh api -X DELETE repos/OWNER/NAME/git/refs/uploads/misc/KEY\n```\n\nDeleting the ref makes the orphan commits unreachable, and GitHub eventually GCs the blob storage.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fenthus-appdev%2Fgh-attach","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fenthus-appdev%2Fgh-attach","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fenthus-appdev%2Fgh-attach/lists"}