Attach & share

Attach & share

The commands that get media onto a PR, an issue, or a plain URL — capture, annotate, and attach.

Attach files to a PR #

attach is the command you'll use most. From a checked-out branch with an open pull request, it needs no arguments beyond the files:

uploads attach ./screenshot.png

…which lands on the pull request as one managed comment:

Run attach again with new files and this same comment updates in place, so your PR doesn't collect a trail of stale screenshot comments.

It uses the gh CLI to find the current repo and PR. If you're not on a PR branch, or you want a different target, say so explicitly:

uploads attach ./shot.png --pr 123 # a specific PR
uploads attach ./shot.png --issue 45 # an issue instead
uploads attach ./shot.png --repo owner/name --pr 123 # a different repo

Want the URLs and ready-to-paste Markdown without touching any GitHub comment (say, to drop an image into a PR description yourself)?

uploads attach ./shot.png --no-comment # print URLs, post nothing

Stage before a PR exists #

Don't wait for a PR to capture something. As of issue #403, a bare put on a non-default git branch already stages by default — no --branch flag needed:

uploads put ./after.png

A bare uploads screenshot (no --pr/--issue/ --branch) stages the same way, carrying its derived path/ url/env/viewport metadata through promotion — so capturing before the PR exists no longer loses that context.

attach --branch is the explicit, multi-file form of the same thing — reach for it to upload several files at once with shared flags, or to force staging outside a plain upload:

uploads attach --branch ./before.png ./after.png

See what's queued for the current branch, and whether it'll actually auto-attach, with uploads staged:

uploads staged
after.webp  94.2 KB  staged 2026-07-22T14:00:00Z  https://storage.uploads.sh/gh/you/app/branch/feat-nav/after.webp
binding: self — these auto-attach when this branch's PR opens
once the PR exists: uploads attach --promote

The binding line is the thing worth reading: self means these files will actually auto-attach when the PR opens; none or other means they won't (the repo isn't linked to this workspace, or it's linked to a different one), and unknown means the check itself couldn't run. Pass --format json for a machine-readable version — it always prints a valid document, even with zero files staged. See GitHub App: staging before a PR exists for what each binding state means and how to fix it.

Once the PR opens, the next attach against it promotes those staged files automatically — or run uploads attach --promote with no files to do it explicitly (--no-promote opts out). Promotion is copy-and-keep: the staged original is never deleted, so a URL you already embedded (in the PR body, say, before it was even opened) keeps serving.

Pair a before and after #

Tag a capture with --state before or --state after and the two render side by side in the comment, under Before and After headings, instead of stacking:

uploads put ./hero-before.webp --state before
uploads put ./hero-after.webp --state after

Two files pair when they share a --meta path value and one is before while the other is after. That's the form to use when a single comment carries several pairs — the path is what keeps them from crossing:

uploads put ./settings-old.webp --meta path=/settings --state before

With no usable path, filenames are the fallback: stems that differ only by a before or after token pair up, so hero-before.webp goes with hero-after.webp. The token has to be its own word — beforehand.webp doesn't match. A group with two befores and one after is ambiguous and won't pair; it falls back to the normal stacked list. Only images pair.

Pairing isn't only a comment thing — a file's public page shows its counterpart next to it, with a link either way. --state also takes empty, error, and loading for the other states worth capturing; those are searchable with uploads find but don't pair.

Get a URL for any file #

put is the simpler building block: upload one file, get back a public URL and Markdown you can paste anywhere.

uploads put ./shot.png
# on a non-default git branch, bare put stages for that branch
>> optimized 411.5 KB → 94.2 KB (shot.webp)
URL: https://storage.uploads.sh/gh/you/app/branch/feat-shot/shot.webp
MARKDOWN: ![shot.png](https://storage.uploads.sh/gh/you/app/branch/feat-shot/shot.webp)
note: staged for branch feat/shot — auto-comments to pull request when opened
(or run: uploads attach --promote once it exists). Use --ref/--prefix for a
plain dated upload.

On a branch, a bare put stages the file for that branch's future PR — same behavior as attach, above. Everywhere else it keeps the plain dated path shown next.

# on the default branch (or --no-git), the classic dated layout applies
>> optimized 411.5 KB → 94.2 KB (shot.webp)
URL: https://storage.uploads.sh/screenshots/app/2026-07-12/shot-9f2c1a.webp
MARKDOWN: ![shot.png](https://storage.uploads.sh/screenshots/app/2026-07-12/shot-9f2c1a.webp)

A few useful variations:

uploads put ./shot.png --pr 123 --comment # also post to the PR
uploads put ./shot.png --no-optimize # upload the original bytes
uploads put ./mobile.png --frame phone # add device chrome

By default, images are re-encoded to WebP (capped size, high quality) so GitHub embeds stay fast, and EXIF is stripped from the uploaded bytes. Use --keep-exif to keep it, or --no-optimize to skip all processing.

Before stripping, a small allowlist is read off the image and stored as searchable metadata: viewport, device, software, and captured. Those values are publicly visible on the file's page. GPS coordinates, serial numbers, and personal-name tags are never kept. Pass --no-auto to store none of it.

Each file also gets a shareable page on uploads.sh (under /f/…) that supports oEmbed — so chat apps, notes tools, and other unfurlers can embed the image when you paste the page link. Details are in Reference.

Details: any of --pr, --issue, --key, --ref, --prefix, or --destination opts a bare put out of branch staging, as does --no-git. Staged keys have no content-hash suffix (unlike the dated layout above) — they're stable per filename, so re-uploading the same file replaces it in place instead of minting a new URL.

Capture a screenshot #

Don't have the image yet? screenshot renders a URL or a local.html file and hosts the result in one step, with no browser install required.

uploads screenshot https://app.example --pr 123 --comment
# renders the page, then hosts the WebP in one step
>> captured via local backend
URL: https://storage.uploads.sh/gh/app/example/pull/123/app.example.webp
MARKDOWN: ![app.example](https://storage.uploads.sh/gh/app/example/pull/123/app.example.webp)

A few useful variations:

uploads screenshot ./report.html --dark --selector "main" # dark scheme
uploads screenshot ./card.html --no-upload --out ./card.png # no hosting
uploads screenshot http://localhost:4321 --reduced-motion # clean dev-server shot

If a Chrome or Chromium is already on the machine, screenshot drives it directly (nothing to download); otherwise it renders on uploads.sh servers. Force either side with --via local or --via remote. Note thatlocalhost URLs are only reachable locally.

--out also writes a sidecar, <file>.uploads.json, recording this capture's derived metadata (path/url/env/viewport, plus --state if given) and a content hash. A later put or attach of that same file picks the metadata back up automatically — pass --no-sidecar to skip writing it.

When you shoot your own dev server, it hides known framework dev toolbars (Astro, Next, Nuxt, Vite) automatically (opt out with--no-hide-dev-tools) and takes --reduced-motion to settle animations. Hide any other overlay with --hide <selector>(repeatable), or run setup JS first with --eval <js> /--init-script <file> (local backend).

Annotate a screenshot #

Point reviewers at what changed — boxes, arrows, labels, freeform strokes, and redactions bake into the image before it is hosted. Two entry points, same JSON spec:

uploads screenshot http://localhost:4321/settings --via local --annotate ./callouts.json
uploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.png

Prefer screenshot --annotate when you control the page: CSS selectors resolve against the live DOM (local capture backend only in v1 — remote rejects selector-bearing specs). Use annotate on an image you already have — pixel coordinates only, no selectors.

A minimal callouts file:

{
  "version": 1,
  "annotations": [
    { "type": "box", "selector": "#save-button" },
    { "type": "label", "text": "New: bulk save", "selector": "#save-button" },
    { "type": "redact", "selector": "[data-testid=api-key]", "style": "solid" }
  ]
}

Annotation types: box, arrow, label,draw, redact, and svg. House style is fixed (hand-drawn stroke); optional per-annotation color is the only style knob. Use redact with style: "solid"for secrets — hosted files are public, and blurred text can be recoverable.

Full spec format, selector vs pixel rules, and workflow notes live in the annotate-screenshots agent skill (uploads install or npx skills add buildinternet/uploads). See also uploads annotate --help and uploads screenshot --help.