Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

GitHub PR Attachment Service

Give coding agents a reliable way to add screenshots, logs, PDFs, and other files to GitHub pull requests.

When an agent cannot use GitHub's browser-only attachment interface, this self-hosted service fills the gap. The agent uploads a local file, receives ready-to-paste Markdown, and adds it to a pull request through the normal GitHub API or CLI.

  • Works with Codex, Claude Code, shell scripts, and other automated tools.
  • Runs in your own Cloudflare account using a Worker and a private R2 bucket.
  • Requires no GitHub credentials and never calls GitHub itself.
  • Uses free-tier-conscious storage, request, and retention limits by default.

Attachment URLs are public to anyone who possesses them. Use the service only for nonsensitive files.

Demo

Demo PR #1 shows the complete workflow. The images in its description were created on two different machines and uploaded by an agent through this service—there was no manual GitHub attachment step.

How it works

  1. An agent runs the included upload skill or CLI with a local file.
  2. Your Cloudflare Worker authenticates the upload and stores the file in private R2.
  3. The service returns an opaque public URL and ready-to-paste Markdown.
  4. The agent places that Markdown in the pull request body or a comment.
Agent -> your Worker -> private R2
|
GitHub PR <--- Markdown URL

Quick start

You need:

  • A Cloudflare account with Workers and R2 available.
  • Node.js 24 or later.
  • A short-lived Cloudflare API token scoped to your account with:
    • Workers Scripts: Edit
    • Workers R2 Storage: Edit
    • Account Settings: Read

1. Deploy your service

git clone https://github.com/none23/github-attachments.git
cd github-attachments
npm ci

Save the Cloudflare deployment credential outside Git:

config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/github-pr-attachments"
mkdir -p "$config_dir"
chmod 700 "$config_dir"$EDITOR"$config_dir/deploy.env"
chmod 600 "$config_dir/deploy.env"

deploy.env contains:

CLOUDFLARE_ACCOUNT_ID=your-account-idCLOUDFLARE_API_TOKEN=your-short-lived-deployment-token

Create a private bucket and deploy the Worker:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket github-pr-attachments-yourname \
--create-bucket

Setup creates the bucket, lifecycle rule, Worker secret, and user-level upload profile. Once it succeeds, remove or revoke the Cloudflare deployment token unless this machine also needs to administer the service.

2. Install the agent skill and CLI

mkdir -p "$HOME/.codex/skills""$HOME/.claude/skills"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.codex/skills/attach-github-pr-files"
ln -s "$PWD/skills/attach-github-pr-files" \
"$HOME/.claude/skills/attach-github-pr-files"
npm link

The symlinks install the same skill for Codex and Claude Code without copying it.

3. Attach a file

Ask the agent to attach a file to a pull request, or use the CLI directly:

github-attach screenshot.png --alt "Settings after the change"

The command prints Markdown:

![Settings after the change](https://your-worker.your-subdomain.workers.dev/a/.../screenshot.png)

Pass --json to receive the complete upload response. Diagnostics and errors go to standard error.

Deployment options

Each deployment has one Worker, one upload token, and one statically bound R2 bucket. Upload requests cannot select or override the bucket.

To reuse an existing private bucket, omit --create-bucket and choose a unique prefix:

npm run setup -- \
--worker github-pr-attachments-yourname \
--bucket your-existing-private-bucket \
--prefix github-pr-attachments-yourname/objects/

The default prefix is <worker-name>/objects/; the default retention is 180 days. Use --prefix and --retention-days to change them. Distinct prefixes allow multiple deployments to share one bucket safely.

Setup generates an ignored wrangler.user.jsonc and writes the runtime profile to:

${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/url
${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/token

The upload token can use this service but cannot administer Cloudflare.

Configuration reference

Upload token

The CLI and agent skill use the first nonempty token in this order:

PriorityLocationScope
1GITHUB_ATTACHMENTS_TOKEN in the repository-root .envRepository override
2GITHUB_ATTACHMENTS_TOKEN in the process environmentProcess override
3${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/tokenUser default

For a repository-specific token:

GITHUB_ATTACHMENTS_TOKEN=repository-specific-token

Keep the repository .env uncommitted. User profile files and .env are parsed as data rather than sourced as shell code.

Service URL

The service URL is required and resolves separately:

PriorityLocationScope
1GITHUB_ATTACHMENTS_URL in the process environmentProcess override
2${XDG_CONFIG_HOME:-~/.config}/github-pr-attachments/urlUser deployment

Repository .env cannot redirect a user-level token to another service, and there is no shared-service fallback.

HTTP API

The raw API accepts the file body directly:

curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
-H "X-Filename: screenshot.png" \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"$GITHUB_ATTACHMENTS_URL/v1/attachments"

For non-ASCII filenames, percent-encode the UTF-8 filename in X-Filename and add X-Filename-Encoding: percent. The included CLI and agent skill do this automatically.

See openapi.yaml for the complete contract.

Technical overview

Agent -> authenticated Worker -> atomic quota reservation -> private R2
GitHub -> opaque public URL -> Worker security headers -> private R2
  • The Worker authenticates mutations, validates requests, streams file bodies, and generates Markdown.
  • R2 remains private; downloads pass through the Worker.
  • A SQLite-backed Durable Object coordinates the service-wide quota.
  • Durable Object alarms clean up expired objects and abandoned reservations.
  • A prefix-scoped R2 lifecycle rule provides expiration defense in depth.

PNG, JPEG, GIF, and WebP responses can render inline. Other media types are served as downloads with nosniff and a restrictive content security policy.

Default limits

LimitDefault
Stored data8 GB
Objects50,000
Raster image size10 MB
Other file size25 MB
Upload attempts per day1,000
Upload attempts per month100,000
Retention180 days

Reservations are atomic under concurrent uploads. Failed attempts still count toward daily and monthly operation limits.

Operations

Check health and quota:

curl "$GITHUB_ATTACHMENTS_URL/healthz"
curl --fail-with-body \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/quota"

Delete an attachment:

curl --fail-with-body -X DELETE \
-H "Authorization: Bearer $GITHUB_ATTACHMENTS_TOKEN" \
"$GITHUB_ATTACHMENTS_URL/v1/attachments/$ATTACHMENT_ID"

Cloudflare budget alerts provide additional visibility but do not stop spending. The application limits, private bucket, and platform request limits are the primary cost boundaries.

Local development

cp .dev.vars.example .dev.vars
npm install
npm run types
npm run check
npm run dev

npm run check runs formatting and lint checks, generated binding drift checks, strict TypeScript checks, Workers-runtime integration tests, and CLI/setup tests.

Security model

  • A random 256-bit bearer token protects upload, delete, and quota routes.
  • Secret comparison uses Cloudflare's constant-time Web Crypto operation.
  • Public IDs are cryptographically random UUIDs and cannot be listed through the service.
  • User filenames never become R2 keys and cannot set response headers.
  • Public URLs are capability URLs, not private-repository authorization.
  • The service does not scan downloads for malware.

Attachment enumeration

The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment list or search route. Each public attachment URL combines a cryptographically random UUID with the exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment GET and HEAD requests with an incorrect filename receive the same 404 response as a missing attachment, while missing or malformed filename paths also return 404.

These controls make blind enumeration impractical, but they are not access control. Filenames are often predictable, and anyone who obtains a complete attachment URL can access it until it expires or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other unintended locations.

Design and license

See DESIGN.md for the original proposal and design rationale.

This project is available under the MIT License.

About

Cloudflare service for programmatic GitHub pull request attachments

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages