feat: add download subcommand for user-attachments URLs (#42) - #46
Merged
Conversation
drogers0
force-pushed
the
feat/download-attachments
branch
5 times, most recently
from
August 7, 2026 22:02
7fb20a2 to
67204ec
Compare
drogers0
force-pushed
the
feat/download-attachments
branch
2 times, most recently
from
August 17, 2026 21:12
8ba2add to
4932f52
Compare
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
force-pushed
the
feat/download-attachments
branch
from
August 17, 2026 21:29
4932f52 to
f53dfad
Compare
drogers0
marked this pull request as ready for review
August 17, 2026 21:58
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the plan on #42. Adds
gh image downloadto fetch attachments back out of GitHub.Why
gh-imageuploaded attachments but could not fetch them. Doing it by hand is not something you would guess — two concrete traps:curl -Oon an attachment URL writes 0 bytes and exits 0 — without-Lit saves the empty redirect body and reports success.curl -LOon an/assets/URL produces an extensionless uuid — those URLs carry no filename, and only the redirect reveals the type.Shape
--output-dir <dir>--output <file>--output -Overwrites by default, as
curl -Odoes.--no-clobbersuffixes.1,.2instead, matching curl's flag of the same name.How it works
A
GETon the attachment URL answers with a302to a presigned storage URL, so there are two legs with opposite credential requirements:Authorizationheader on the S3 bucket returns400, and that failure is invisible onobjects.githubusercontent.com, so it is easy to ship.Neither client follows redirects. The redirect is classified in a fixed order —
/loginongithub.commeans the session is stale; a target carryingX-Amz-Signatureis 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 matchingContent-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
404is 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: a500says nothing about the credential, so retrying it would only burn the fast path. An explicit--tokenorGH_SESSION_TOKENpins the run to the session route, matchinguseBearerRoute.Filenames and writes
Names come from the URL, never a response header:
/files/URLs carry their name and GitHub validates it (a tampered name returns404), 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
--outputhas 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
--issuescanning — the caller who rangh issue viewis already looking at both the URL and its label, and a pipe reproduces the rest:Better to be a good
xargscitizen than to reimplement discovery. It also keeps the tool clear of the confused-deputy question in #39, since attachment URLs carry no repository.github.comonly — GHES is not supported and nothing claims otherwise.Validation
gofmt -l .go vet ./...go test -race -cover ./...internal/download89.5%,main85.5%golangci-lint v2.12.2GOOS=android GOARCH=arm64cross-compileVerified end to end against real assets: a private 901 KB PNG plus public zip/PNG, on both routes (bearer, and
GH_SESSION_TOKENpinning the session), each output mode, overwrite,--no-clobbersuffixing 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
/logindetection 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.