|
1 | | -# gh-image |
| 1 | +<p align="center"> |
| 2 | + <img src="https://github.com/user-attachments/assets/92463e67-b897-4212-91b4-a4f9b80ec4d4" alt="gh-image banner" width="640"> |
| 3 | +</p> |
2 | 4 |
|
3 | | - |
| 5 | +<p align="center"> |
| 6 | + <em>Drop images into GitHub issues, PRs, and READMEs, straight from the command line.</em> |
| 7 | +</p> |
4 | 8 |
|
5 | | -A `gh` CLI extension that uploads images to GitHub from the command line. |
| 9 | +<p align="center"> |
| 10 | + <a href="https://github.com/drogers0/gh-image/releases/latest"><img src="https://img.shields.io/github/v/release/drogers0/gh-image?color=blue" alt="Latest release"></a> |
| 11 | + <a href="https://github.com/drogers0/gh-image/stargazers"><img src="https://img.shields.io/github/stars/drogers0/gh-image?style=flat&color=yellow" alt="GitHub stars"></a> |
| 12 | + <a href="https://github.com/drogers0/gh-image/releases"><img src="https://img.shields.io/github/downloads/drogers0/gh-image/total?color=green" alt="Total downloads"></a> |
| 13 | + <a href="LICENSE"><img src="https://img.shields.io/github/license/drogers0/gh-image?color=lightgrey" alt="License: MIT"></a> |
| 14 | + <a href="https://goreportcard.com/report/github.com/drogers0/gh-image"><img src="https://goreportcard.com/badge/github.com/drogers0/gh-image" alt="Go Report Card"></a> |
| 15 | +</p> |
6 | 16 |
|
7 | | -GitHub has no public API for image uploads — the web UI uses an internal endpoint that produces URLs scoped to a repository's visibility. This tool replicates that flow, so images on private repos stay private. |
| 17 | +--- |
8 | 18 |
|
9 | | -## Usage |
| 19 | +GitHub has no public API for image uploads. The web UI uses an internal endpoint that produces `user-attachments` URLs whose visibility is scoped to the repository they were uploaded to. `gh-image` replicates that flow as a `gh` CLI extension, so you can drop a screenshot into a bug report, README, or Slack thread without leaving the terminal — and images on private repos stay private. |
10 | 20 |
|
11 | | -```bash |
12 | | -# Upload an image and get the markdown (infers repo from current git workspace) |
13 | | -gh image screenshot.png |
| 21 | +```console |
| 22 | +$ gh image screenshot.png |
| 23 | + |
| 24 | +``` |
14 | 25 |
|
15 | | -# Upload multiple images |
16 | | -gh image img1.png img2.png |
| 26 | +## Installation |
17 | 27 |
|
18 | | -# Explicit repo (when not in a git workspace or targeting a different repo) |
19 | | -gh image screenshot.png --repo owner/repo |
| 28 | +```bash |
| 29 | +gh extension install drogers0/gh-image |
20 | 30 | ``` |
21 | 31 |
|
22 | | -Output: |
23 | | -``` |
24 | | - |
| 32 | +That's it. The [`gh` CLI](https://cli.github.com) auto-detects your platform and downloads the prebuilt binary. Pre-built releases ship for **macOS** (arm64, amd64), **Linux** (amd64, arm64), and **Windows** (amd64). |
| 33 | + |
| 34 | +<details> |
| 35 | +<summary>Build from source</summary> |
| 36 | + |
| 37 | +```bash |
| 38 | +git clone https://github.com/drogers0/gh-image |
| 39 | +cd gh-image |
| 40 | +go build -o gh-image |
| 41 | +gh extension install . |
25 | 42 | ``` |
26 | 43 |
|
27 | | -### Example: Upload an image and attach it to a GitHub issue |
| 44 | +Requires Go 1.26+. |
| 45 | + |
| 46 | +</details> |
| 47 | + |
| 48 | +## Usage |
28 | 49 |
|
29 | 50 | ```bash |
30 | | -# Upload the image |
31 | | -gh image screenshot.png --repo owner/repo |
32 | | -# Output:  |
| 51 | +# Upload an image (infers repo from the current git workspace) |
| 52 | +gh image screenshot.png |
33 | 53 |
|
34 | | -# Use the output in a new issue |
35 | | -gh issue create --repo owner/repo \ |
36 | | - --title "Bug report" \ |
37 | | - --body "Here's what I see: |
| 54 | +# Upload multiple images at once |
| 55 | +gh image hero.png diagram.png chart.png |
38 | 56 |
|
39 | | - |
40 | | -" |
| 57 | +# Target a specific repository |
| 58 | +gh image screenshot.png --repo owner/repo |
41 | 59 | ``` |
42 | 60 |
|
43 | | -## Install |
| 61 | +Each successful upload prints a ready-to-paste markdown reference on its own line: |
44 | 62 |
|
45 | | -```bash |
46 | | -gh extension install drogers0/gh-image |
| 63 | +``` |
| 64 | + |
| 65 | + |
47 | 66 | ``` |
48 | 67 |
|
49 | | -## How It Works |
| 68 | +If any upload fails, the error is printed to stderr and the process exits non-zero — other images in the batch still upload. |
50 | 69 |
|
51 | | -1. Reads your GitHub session cookie from your browser's local cookie store (encrypted cookie decryption handled automatically) |
52 | | -2. Uploads the image through GitHub's internal asset upload flow (the same one the web UI uses) |
53 | | -3. Prints a markdown image reference to stdout |
| 70 | +### Pipe directly into an issue, PR, or comment |
54 | 71 |
|
55 | | -The upload produces `https://github.com/user-attachments/assets/<uuid>` URLs — the same format as drag-and-drop uploads in the browser. Images inherit the repository's visibility: private repo images require authentication to view. |
| 72 | +From inside the repo's working directory, both `gh image` and `gh issue create` infer the target repository automatically: |
56 | 73 |
|
57 | | -See [documentation/github-image-upload-flow.md](documentation/github-image-upload-flow.md) for the full reverse-engineered upload protocol. |
| 74 | +```bash |
| 75 | +gh issue create \ |
| 76 | + --title "Login button stuck in loading state" \ |
| 77 | + --body "Repro on staging: |
| 78 | +
|
| 79 | +$(gh image bug.png) |
| 80 | +
|
| 81 | +Happens consistently after the third click." |
| 82 | +``` |
58 | 83 |
|
59 | 84 | ## Authentication |
60 | 85 |
|
61 | | -No tokens or OAuth setup required. The tool reads your `user_session` cookie directly from your browser's cookie database on disk. On macOS, a Keychain prompt may appear on first use to authorize access to the browser's encryption key. |
| 86 | +`gh-image` authenticates with your existing GitHub session — **no tokens to provision, no OAuth scopes to configure** for everyday local use. The tool reads the `user_session` cookie from your browser's encrypted cookie store. |
62 | 87 |
|
63 | | -Supported browsers: |
64 | | -- Chrome |
65 | | -- Brave |
66 | | -- Chromium |
67 | | -- Edge |
68 | | -- Firefox |
69 | | -- Opera |
70 | | -- Safari |
| 88 | +**Supported browsers:** Chrome · Brave · Chromium · Edge · Firefox · Opera · Safari |
71 | 89 |
|
72 | | -Supported platforms: |
73 | | -- macOS |
74 | | -- Linux |
75 | | -- Windows |
| 90 | +**Supported platforms:** macOS · Linux · Windows |
76 | 91 |
|
77 | | -## Session Token |
| 92 | +On macOS, a Keychain prompt may appear on first use to authorize access to your browser's cookie encryption key. Click **Always Allow** to skip future prompts. |
78 | 93 |
|
79 | | -The tool supports providing your own session token instead of reading from the browser. This enables use in environments where browser cookies are unavailable (such as CI). |
| 94 | +> [!NOTE] |
| 95 | +> **Windows + Chrome 127+:** Some versions of Chrome on Windows are not yet supported by the underlying cookie library. Use another browser or [investigate potential workarounds](https://github.com/drogers0/gh-image/issues/4). |
80 | 96 |
|
81 | | -> **Warning:** `user_session` cookies grant full account access — they are not |
82 | | -> scoped like personal access tokens. Treat them with the same care as a password. |
83 | | -> Rotate promptly if leaked. |
| 97 | +### Session token override |
84 | 98 |
|
85 | | -**Token resolution order (highest priority first):** |
| 99 | +For CI, headless environments, or shared machines, you can supply the session token explicitly. Resolution order (first match wins): |
86 | 100 |
|
87 | | -1. `--token <value>` flag |
88 | | -2. `GH_SESSION_TOKEN` environment variable |
89 | | -3. Browser cookie extraction (default) |
| 101 | +| Priority | Source | When to use | |
| 102 | +|---|---|---| |
| 103 | +| 1 | `--token <value>` flag | One-off invocations | |
| 104 | +| 2 | `GH_SESSION_TOKEN` env var | CI/CD, shared machines | |
| 105 | +| 3 | Browser cookie store | Local interactive use (default) | |
90 | 106 |
|
91 | 107 | ```bash |
92 | | -# Use a token via flag |
| 108 | +# Flag (visible in process listings like `ps aux` — avoid on shared machines) |
93 | 109 | gh image --token "$MY_TOKEN" screenshot.png --repo owner/repo |
94 | 110 |
|
95 | | -# Use a token via environment variable (preferred on shared machines) |
| 111 | +# Environment variable (preferred — not visible to `ps aux`) |
96 | 112 | GH_SESSION_TOKEN="$MY_TOKEN" gh image screenshot.png --repo owner/repo |
97 | 113 | ``` |
98 | 114 |
|
99 | | -> **Security note:** `--token` values are visible in process listings (`ps aux`). |
100 | | -> Use the `GH_SESSION_TOKEN` environment variable on shared machines. |
| 115 | +> [!WARNING] |
| 116 | +> `user_session` cookies grant **full account access** — they are not scoped like personal access tokens. Treat them with the same care as a password. If leaked, **[sign out of GitHub](https://github.com/logout)** on the machine that holds the session; if you are not on that machine, revoke it through [Settings → Sessions](https://github.com/settings/sessions), or [change your password](https://github.com/settings/security) (which kills every session in one action). |
101 | 117 |
|
102 | 118 |
|
103 | | -## Token Utilities |
| 119 | +## CI / CD |
104 | 120 |
|
105 | | -### `extract-token` |
| 121 | +`gh-image` runs unattended in GitHub Actions when given a session token via `GH_SESSION_TOKEN`. |
106 | 122 |
|
107 | | -Extracts your session token from the browser and prints it to stdout. Useful for storing your token as a CI secret. |
| 123 | +> [!CAUTION] |
| 124 | +> **Use a dedicated bot account for CI/CD on shared repos.** GitHub hides secret values in the UI and masks log emissions, but a determined collaborator with write access can craft a workflow that exfiltrates the value through channels masking doesn't cover. Storing your *personal* `user_session` means such a leak compromises your account; a bot account scopes the blast radius to that bot. Decide whose token to extract in step 1 below accordingly. |
108 | 125 |
|
109 | | -```bash |
110 | | -# Extract and print token |
111 | | -gh image extract-token |
| 126 | +**Setup** |
112 | 127 |
|
113 | | -# Store in a variable |
114 | | -TOKEN=$(gh image extract-token) |
115 | | -``` |
| 128 | +1. Run `gh image extract-token` locally to capture the token (token → stdout, status → stderr), then run `gh image check-token --token <token>` to confirm it authenticates as the intended user (username → stdout on success, exit code `0` = valid). |
| 129 | +2. Create a GitHub environment (Settings → Environments → New environment), e.g. `gh-image`, and restrict deployment branches to a trusted set (e.g. `main` only). |
| 130 | +3. Add the token as an **environment secret** named `GH_SESSION_TOKEN` on that environment. |
116 | 131 |
|
117 | | -### `check-token` |
| 132 | +```yaml |
| 133 | +jobs: |
| 134 | + upload: |
| 135 | + runs-on: ubuntu-latest |
| 136 | + environment: gh-image # binds this job to the scoped environment |
| 137 | + steps: |
| 138 | + - name: Upload screenshots |
| 139 | + env: |
| 140 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for gh CLI auth |
| 141 | + GH_SESSION_TOKEN: ${{ secrets.GH_SESSION_TOKEN }} # for the upload itself |
| 142 | + run: | |
| 143 | + gh extension install drogers0/gh-image |
| 144 | + gh image check-token # optional: fail fast if the session expired |
| 145 | + gh image screenshot.png --repo ${{ github.repository }} |
| 146 | +``` |
118 | 147 |
|
119 | | -Verifies that a session token is valid and, on a best-effort basis, prints the authenticated GitHub username to stdout. Exit code 0 means valid, exit code 1 means invalid or an error occurred. Username output may be empty even when the token is valid (for example, if the `<meta name="user-login">` tag is missing or cannot be parsed), so rely on the exit code for validity checks. |
| 148 | +> [!NOTE] |
| 149 | +> `user_session` cookies expire when GitHub invalidates the session. A scheduled `check-token` job is the cleanest way to detect expiry before it breaks a real run. |
120 | 150 |
|
121 | | -```bash |
122 | | -# Check token from flag |
123 | | -gh image check-token --token "$MY_TOKEN" |
| 151 | +## How it works |
124 | 152 |
|
125 | | -# Check token from environment variable |
126 | | -GH_SESSION_TOKEN="$MY_TOKEN" gh image check-token |
| 153 | +1. Resolves a `user_session` cookie from the configured source (flag → env → browser). |
| 154 | +2. Fetches the target repository's page to obtain an `uploadToken` from the embedded JS payload. |
| 155 | +3. Requests an S3 upload policy from `/upload/policies/assets`. |
| 156 | +4. Uploads the file directly to S3 using the presigned form fields. |
| 157 | +5. Calls back to GitHub to finalize the asset. |
| 158 | +6. Prints `` to stdout. |
127 | 159 |
|
128 | | -# Check token from browser (default) |
129 | | -gh image check-token |
| 160 | +The final URL is the standard `https://github.com/user-attachments/assets/<uuid>` format — visibility inherits from the target repository, so a private-repo upload requires authentication to view. |
130 | 161 |
|
131 | | -# Capture username in a script |
132 | | -USERNAME=$(gh image check-token --token "$MY_TOKEN") |
133 | | -``` |
| 162 | +For the full architecture, see **[documentation/architecture.md](documentation/architecture.md)**. For the reverse-engineered upload protocol, see **[documentation/github-image-upload-flow.md](documentation/github-image-upload-flow.md)**. |
134 | 163 |
|
135 | | -## CI/CD Usage |
| 164 | +## Requirements |
136 | 165 |
|
137 | | -> **Note:** `user_session` cookies are real user login sessions, not scoped automation credentials. They expire when GitHub invalidates the session. Use a pre-push hook or periodic job to validate the token (see `check-token`) and rotate it when needed. |
| 166 | +- A supported browser with an active GitHub session — or a `GH_SESSION_TOKEN` for CI. |
| 167 | +- Write access to the target repository (uploads require it). |
| 168 | +- A target repository — pass `--repo owner/repo`, or run from a git workspace whose `origin` remote is on GitHub. |
| 169 | +- The `gh` CLI must be installed and authenticated (used for repository ID lookup). |
138 | 170 |
|
139 | | -### Setup |
| 171 | +## Limitations |
140 | 172 |
|
141 | | -1. Extract your token locally: |
142 | | - ```bash |
143 | | - gh image extract-token |
144 | | - ``` |
145 | | -2. Add the output as a repository secret named `GH_SESSION_TOKEN` in your GitHub repository settings. |
| 173 | +- Uses an **undocumented** internal GitHub API that may change without notice. |
| 174 | +- `uploadToken` is only issued to users with write access on the target repository. |
| 175 | +- Session cookies are not scoped credentials; they expire when GitHub invalidates the session. |
146 | 176 |
|
147 | | -### Example GitHub Actions workflow |
| 177 | +## Contributing |
148 | 178 |
|
149 | | -```yaml |
150 | | -- name: Upload screenshots |
151 | | - env: |
152 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Required for gh CLI auth |
153 | | - GH_SESSION_TOKEN: ${{ secrets.GH_SESSION_TOKEN }} # GitHub session token |
154 | | - run: | |
155 | | - gh extension install drogers0/gh-image |
156 | | - gh image --repo owner/repo screenshot.png |
157 | | -``` |
| 179 | +Issues and pull requests are welcome. For bug reports, please include: |
158 | 180 |
|
159 | | -> **Note:** `gh` CLI must be authenticated (via `GH_TOKEN` or equivalent) for |
160 | | -> extension installation and repository ID lookup. `GH_SESSION_TOKEN` is the |
161 | | -> session token used for the image upload itself. |
| 181 | +- Your OS and browser |
| 182 | +- The exact `gh image` invocation |
| 183 | +- The error output (with any session token values redacted) |
162 | 184 |
|
163 | | -### Validate token before upload |
| 185 | +Before opening a PR, run `go test ./...` and `go vet ./...`. |
164 | 186 |
|
165 | | -```yaml |
166 | | -- name: Validate session token |
167 | | - env: |
168 | | - GH_SESSION_TOKEN: ${{ secrets.GH_SESSION_TOKEN }} |
169 | | - run: gh image check-token |
170 | | -
|
171 | | -- name: Upload screenshots |
172 | | - env: |
173 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
174 | | - GH_SESSION_TOKEN: ${{ secrets.GH_SESSION_TOKEN }} |
175 | | - run: | |
176 | | - gh extension install drogers0/gh-image |
177 | | - gh image --repo owner/repo screenshot.png |
178 | | -``` |
| 187 | +## Support |
179 | 188 |
|
180 | | -### Cache extension for faster CI runs |
| 189 | +If `gh-image` saves you a few drag-and-drops, a ⭐ helps others find it: |
181 | 190 |
|
182 | | -```yaml |
183 | | -- name: Cache gh extensions |
184 | | - uses: actions/cache@v4 |
185 | | - with: |
186 | | - path: ~/.local/share/gh/extensions |
187 | | - key: gh-extensions-${{ runner.os }} |
| 191 | +```bash |
| 192 | +gh api --method PUT user/starred/drogers0/gh-image |
188 | 193 | ``` |
189 | 194 |
|
190 | | -## Requirements |
| 195 | +(or just click the star at the [top of this page](https://github.com/drogers0/gh-image)) |
191 | 196 |
|
192 | | -- A supported browser with an active GitHub session (for local use) |
193 | | -- Write access to the target repository |
194 | | -- For CI: a valid `GH_SESSION_TOKEN` secret (see [CI/CD Usage](#cicd-usage)) |
195 | | - |
196 | | -## Limitations |
| 197 | +## License |
197 | 198 |
|
198 | | -- This tool uses an undocumented GitHub internal API that could change without notice. |
199 | | -- The `uploadToken` required for uploads is only available to users with write access to the target repository. |
200 | | -- Either `--repo` must be provided or the tool must be run from within a git workspace with a GitHub remote. |
201 | | -- Session tokens are not scoped credentials — they expire with the browser session and should be rotated periodically. |
| 199 | +[MIT](LICENSE) © 2025-2026 drogers0 |
0 commit comments