Skip to content

feat: add download subcommand for user-attachments URLs (#42) - #46

Merged
drogers0 merged 1 commit into
mainfrom
feat/download-attachments
Aug 17, 2026
Merged

feat: add download subcommand for user-attachments URLs (#42)#46
drogers0 merged 1 commit into
mainfrom
feat/download-attachments

Conversation

@drogers0

@drogers0 drogers0 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Implements the plan on #42. Adds gh image download to fetch attachments back out of GitHub.

Why

gh-image uploaded attachments but could not fetch them. Doing it by hand is not something you would guess — two concrete traps:

  • curl -O on an attachment URL writes 0 bytes and exits 0 — without -L it saves the empty redirect body and reports success.
  • curl -LO on an /assets/ URL produces an extensionless uuid — those URLs carry no filename, and only the redirect reveals the type.

Shape

gh image download [--output <file>|-] [--output-dir <dir>] [--no-clobber] [--token <value>] <url>...
Form Behavior
unset derived name in the current directory
--output-dir <dir> derived name in that directory
--output <file> exact path, single URL
--output - stream to stdout, single URL

Overwrites by default, as curl -O does. --no-clobber suffixes .1, .2 instead, matching curl's flag of the same name.

How it works

A GET on the attachment URL answers with a 302 to a presigned storage URL, so there are two legs with opposite credential requirements:

  1. Resolve — authenticated.
  2. Fetch — sends nothing. An Authorization header on the S3 bucket returns 400, and that failure is invisible on objects.githubusercontent.com, so it is easy to ship.

Neither client follows redirects. The redirect is classified in a fixed order — /login on github.com means the session is stale; a target carrying X-Amz-Signature is the asset; anything else is refused. That last rule is what stops an SSO interstitial from being written to disk as a plausible attachment, complete with a matching Content-Length.

Credential routing

Follows #49: bearer token first, browser session as fallback, so a run on the fast path never touches the cookie store and never prompts for it.

Simpler than upload's router, though. The bearer upload endpoint accepts only a narrow set of content types, so upload has to remember rejections per content type. On the way back out one credential reaches every attachment — verified against a private repo for an asset uploaded by a different user, so the grant follows repository read permission rather than uploader identity — so a single rejection turns the fast route off for the whole run.

A 404 is what triggers the fallback, because GitHub answers the same way for an absent asset and for one the credential cannot read. Any other status is surfaced as-is: a 500 says nothing about the credential, so retrying it would only burn the fast path. An explicit --token or GH_SESSION_TOKEN pins the run to the session route, matching useBearerRoute.

Filenames and writes

Names come from the URL, never a response header: /files/ URLs carry their name and GitHub validates it (a tampered name returns 404), while /assets/ URLs carry only a uuid, so the extension comes from the presigned path.

The destination is opened only after the fetch returns 200, so a failed request leaves no 0-byte file — the advantage --output has over shell redirection, which truncates on open. A partial write is removed rather than left behind.

Scope

Deliberately one primitive: fetch a URL you already have. No bulk --issue scanning — the caller who ran gh issue view is already looking at both the URL and its label, and a pipe reproduces the rest:

gh issue view 23 --comments --json body,comments -q '.body, (.comments[].body)' \
  | grep -oE 'https://github\.com/user-attachments/(assets/[0-9a-f-]{36}|files/[0-9]+/[^) "]+)' \
  | sort -u | xargs gh image download

Better to be a good xargs citizen than to reimplement discovery. It also keeps the tool clear of the confused-deputy question in #39, since attachment URLs carry no repository.

github.com only — GHES is not supported and nothing claims otherwise.

Validation

Check Result
gofmt -l . clean
go vet ./... clean
go test -race -cover ./... pass — internal/download 89.5%, main 85.5%
golangci-lint v2.12.2 0 issues
GOOS=android GOARCH=arm64 cross-compile clean

Verified end to end against real assets: a private 901 KB PNG plus public zip/PNG, on both routes (bearer, and GH_SESSION_TOKEN pinning the session), each output mode, overwrite, --no-clobber suffixing to .1/.2, and the error paths — a failed download leaves zero files, and a partial failure exits 1 while still writing the survivor.

Related

The /login detection here is the same class of problem as #41, where an expired session on the upload path surfaces as a permissions error. GitHub signals expiry differently per endpoint, so each call site classifies its own response; the two should word their errors alike.

@drogers0
drogers0 force-pushed the feat/download-attachments branch 5 times, most recently from 7fb20a2 to 67204ec Compare August 7, 2026 22:02
@drogers0
drogers0 force-pushed the feat/download-attachments branch 2 times, most recently from 8ba2add to 4932f52 Compare August 17, 2026 21:12
Adds `gh image download` to fetch attachments back out of GitHub, closing the
half of the attachment problem the tool did not cover.

An attachment URL answers with a 302 to a presigned storage URL, so the flow has
two legs with opposite credential requirements: the first needs a credential,
the second must carry none at all — an Authorization header on the S3 bucket is
rejected with a 400, and that failure is invisible on the other storage host.
Neither client follows redirects; the redirect is classified explicitly so a
login interstitial or error page can never be written to disk as a plausible
attachment.

The resolve leg takes the same two routes as upload: the gh CLI's bearer token
first, the browser session as fallback, so a run that stays on the fast path
never touches the cookie store. The routing is simpler than upload's — the
bearer upload endpoint accepts only a narrow set of content types, while one
credential reaches every attachment on the way back out, so a single rejection
turns the fast route off for the rest of the run rather than being remembered
per content type. A 404 is what triggers the fallback, since GitHub answers the
same way for an absent asset and for one the credential cannot read; any other
status is surfaced as-is, because it says nothing about the credential.

Output follows curl's conventions:

  gh image download <url>...                  derived names in the cwd
  gh image download --output-dir <dir> ...    derived names in a directory
  gh image download --output <file> <url>     an exact path
  gh image download --output - <url>          stream to stdout

Existing files are overwritten, as curl -O does; --no-clobber suffixes .1, .2
instead. Filenames come from the URL rather than any response header: /files/
URLs carry their name and GitHub validates it, while /assets/ URLs carry only a
uuid, so the extension comes from the presigned path. The destination is opened
only after the fetch returns 200, so a failed request leaves no 0-byte file, and
a partial write is removed rather than left behind.

Protocol notes are in documentation/github-attachment-download-flow.md.
@drogers0
drogers0 force-pushed the feat/download-attachments branch from 4932f52 to f53dfad Compare August 17, 2026 21:29
@drogers0
drogers0 marked this pull request as ready for review August 17, 2026 21:58
@drogers0
drogers0 merged commit e8757c6 into main Aug 17, 2026
2 checks passed
@drogers0
drogers0 deleted the feat/download-attachments branch August 17, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant