You are installing a file-publishing mechanism called upload-to-issue in this
repository. Read this whole brief before you touch anything.
A person or an agent pushes a branch named upload-to-issue/<N>, where <N> is an
issue or pull request number. The branch holds one or more files at its root. A GitHub
Actions workflow on that branch uploads each file to a storage destination, comments
the links on issue/PR <N>, then deletes the branch.
Four facts decide the design. Do not lose them.
- The workflow travels on the branch.
on: pushruns the workflow file from the ref that was pushed, not from the default branch. So the workflow lives on an orphan branchupload-to-issue/base, and everyupload-to-issue/<N>branch is cut from it and carries a copy. The mechanism therefore works the moment you pushupload-to-issue/base. It does not wait for any pull request to merge. upload-to-issue/baseis an orphan branch. Its history is unrelated to the default branch, and it holds one file: the workflow. That keeps everyupload-to-issue/<N>branch tiny.- A pull request is an issue. On GitHub they share one number space, and
POST /repos/{owner}/{repo}/issues/{N}/commentsposts to both. So the workflow needs one code path, not two. Callgh apidirectly —gh issue commentrejects a pull request number. - The bytes enter the repository permanently. Deleting the branch makes the objects unreachable. It does not remove them. GitHub garbage-collects unreachable objects on its own schedule, and they still count toward repository size until it does. Every upload grows the repository by the file size, whatever the destination is. This is the price of needing only push access. Say this out loud to the user when you present the storage options — it is the fact that decides the size cap.
Do not pick a storage destination yourself. Ask in this order, and wait for an answer each time.
Question 1 — who may read these files? The answer removes most options.
- Not secret, but should not be searchable → public object storage under an unguessable path.
- Readable only by a named group → a destination with its own access list (Google Drive, SharePoint, a private bucket with signed URLs).
- Readable by anyone who can read the repository → the files can stay in git, linked by raw URL.
Question 2 — which destination? Present the options grouped by where the file lives and who can read it, not by vendor. Include:
- In git history. Commit the files to a permanent branch and link
https://github.com/<owner>/<repo>/raw/<sha>/<file>. No credential at all. The files are in the repository forever and cannot be deleted. In a private repository the links demand a login, which may be what the user wants. - Public object storage under a UUID path. S3, DigitalOcean Spaces, Cloudflare R2, Google Cloud Storage. The UUID is the access control. Needs one secret.
- A destination with an access list. Google Drive, SharePoint, Dropbox. Needs a service account or OAuth credential. Link generation differs — see §4.
- Whatever this repository already publishes to. Read
.github/workflows/*.ymlfirst and look for an existing upload step: a bucket, a pages deploy, an artifact store. If you find one, propose reusing its bucket and secret, and say which workflow and which secret you found. Do not reuse it without the user's confirmation — the same credential in a second workflow is a decision, not a detail.
Question 3 — the size cap. Propose 25 MB per file, set as one editable value at the top of the workflow. Give the reason: a slide deck or an annotated screenshot is under 5 MB, 25 MB still admits a short screen recording, and anything larger grows the repository permanently (fact 4). GitHub itself refuses a push containing a file over 100 MB, so the real ceiling exists whether or not you set one; a cap in the workflow fails with a clear message instead of a rejected push.
a. The orphan branch.
git checkout --orphan upload-to-issue/base
git rm -rf .
mkdir -p .github/workflows
# write the workflow (see §4)
git add .github/workflows/upload-to-issue.yml
git commit -m "Add the upload-to-issue workflow"
Do not push yet. See §5.
b. A skill, on a normal branch, in a pull request. Write .agents/skills/upload-to-issue/SKILL.md
or .claude/skills/upload-to-issue/SKILL.md so later sessions find the mechanism without
being told it exists. The skill is the only thing a future agent will read, so it
carries the usage, not the installation. It must say:
- the trigger: the agent has a file that belongs on an issue or pull request, and no way to attach it;
- the steps, exactly — find the number
N,git fetch origin upload-to-issue/base,git checkout -b upload-to-issue/<N> origin/upload-to-issue/base, copy the files to the root, commit,git push origin upload-to-issue/<N>, wait for the comment, then switch back to the working branch; - that the comment is the record, so the agent must not also paste the link by hand;
- the size cap, and that each upload grows the repository;
- what to do when it fails: hand the file to the user with its local path and ask them to upload it. Do not retry in a loop, and do not force it.
Below is a complete, working example. It uploads to DigitalOcean Spaces through
rclone. It is one plug, not the design. The destination is the env block and the
upload step; everything else is the mechanism. rclone also speaks S3, R2, GCS, Google
Drive, Dropbox, OneDrive and SFTP, so most destinations change only the
RCLONE_CONFIG_* values.
One destination is genuinely different, and do not paper over it: Google Drive and
the other access-list destinations authenticate with a service account or OAuth, not
an access key, and the shareable link comes from rclone link after the upload. You
cannot assemble the URL yourself the way you can for S3.
name: Upload to issue
# Lets an agent attach a file to an issue or pull request with no GitHub API token
# and no upload credential of its own. It needs only `git push`.
#
# Flow: push a branch named `upload-to-issue/<N>`, cut from `upload-to-issue/base`,
# with the file(s) at the repo root. Every branch cut from `upload-to-issue/base`
# carries this workflow, so the push triggers it. The job uploads the file(s),
# comments the link(s) on issue/PR #<N>, then deletes the branch.
#
# `upload-to-issue/base` is an orphan branch holding nothing but this file, so every
# `upload-to-issue/<N>` branch stays small.
on:
push:
branches:
# `[0-9]*` and not `*`: a push to `upload-to-issue/base` itself is maintenance,
# not an upload, and must not start a run that can only fail.
- 'upload-to-issue/[0-9]*'
permissions:
contents: write # delete the branch at the end
issues: write # comment on an issue
pull-requests: write # comment on a pull request
concurrency:
group: upload-to-issue-${{ github.ref_name }}
cancel-in-progress: false
env:
# Every upload also grows this repository by the file size, permanently — deleting
# the branch only makes the objects unreachable. Raise this only with that in mind.
MAX_FILE_MB: 25
# --- destination: replace this block to change where files go ---
# An access key ID names a credential; it does not authenticate on its own. The
# only secret is the secret access key.
SPACES_ENDPOINT: https://sgp1.digitaloceanspaces.com
SPACES_BUCKET: example-bucket
SPACES_ACCESS_KEY_ID: EXAMPLEKEYID
SPACES_PUBLIC_BASE: https://example-bucket.sgp1.digitaloceanspaces.com
SPACES_PREFIX: issue-attachments
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Check out the pushed branch
uses: actions/checkout@v4
# The branch name is the only input. Fail loudly rather than guess a number.
- name: Extract the issue number from the branch name
id: target
run: |
branch="${GITHUB_REF_NAME}"
if [[ ! "$branch" =~ ^upload-to-issue/([0-9]+)$ ]]; then
echo "::error::Branch '$branch' is not 'upload-to-issue/<number>'."
exit 1
fi
echo "number=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- name: Collect the files and check their size
id: files
run: |
shopt -s nullglob dotglob
files=()
for f in *; do
[ -f "$f" ] || continue
files+=("$f")
done
if [ ${#files[@]} -eq 0 ]; then
echo "::error::No file at the repo root of '$GITHUB_REF_NAME'."
exit 1
fi
limit=$(( MAX_FILE_MB * 1024 * 1024 ))
for f in "${files[@]}"; do
size=$(stat -c%s "$f")
if [ "$size" -gt "$limit" ]; then
echo "::error::'$f' is $((size / 1024 / 1024))MB; the cap is ${MAX_FILE_MB}MB."
exit 1
fi
done
printf '%s\n' "${files[@]}" > /tmp/files.txt
- name: Install rclone
run: sudo apt-get update -qq && sudo apt-get install -y -qq rclone
# A UUID per file, not a stable name, is what makes the public URL
# unguessable — the object grants read access to anyone holding the URL, so
# the UUID is the actual access control.
- name: Upload the file(s)
env:
RCLONE_CONFIG_DEST_TYPE: s3
RCLONE_CONFIG_DEST_PROVIDER: DigitalOcean
RCLONE_CONFIG_DEST_ENDPOINT: ${{ env.SPACES_ENDPOINT }}
RCLONE_CONFIG_DEST_ACCESS_KEY_ID: ${{ env.SPACES_ACCESS_KEY_ID }}
RCLONE_CONFIG_DEST_SECRET_ACCESS_KEY: ${{ secrets.UPLOAD_TO_ISSUE_SECRET_KEY }}
# Objects are private by default; without this the upload "succeeds" and
# every link 403s. A smoke test catches this; reading the code does not.
RCLONE_CONFIG_DEST_ACL: public-read
# `copyto` checks the bucket exists first. A key that can write objects but
# cannot make bucket-level calls 403s on that check alone.
RCLONE_CONFIG_DEST_NO_CHECK_BUCKET: 'true'
run: |
if [ -z "$RCLONE_CONFIG_DEST_SECRET_ACCESS_KEY" ]; then
echo "::error::UPLOAD_TO_ISSUE_SECRET_KEY repo secret is unset."
exit 1
fi
: > links.txt
while IFS= read -r f; do
id=$(cat /proc/sys/kernel/random/uuid)
key="$SPACES_PREFIX/$id/$f"
# rclone sets Content-Type from the extension, so a PDF or an image
# opens in the browser instead of downloading.
rclone copyto "$f" "dest:$SPACES_BUCKET/$key"
echo "- [$f]($SPACES_PUBLIC_BASE/$key)" >> links.txt
done < /tmp/files.txt
# One endpoint serves both, because a pull request is an issue. `gh issue
# comment` would reject a pull request number here.
- name: Comment on the issue or pull request
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ steps.target.outputs.number }}
run: |
{
echo "An automated workflow uploaded a new file attachment."
echo
cat links.txt
} > comment.md
gh api -X POST "repos/${{ github.repository }}/issues/${NUMBER}/comments" \
-F body=@comment.md
# The branch only ever existed to carry the file(s) to this job. The comment is
# now the permanent record. Deleting only on success leaves a failed branch in
# place to retry from.
- name: Delete the branch
env:
GH_TOKEN: ${{ github.token }}
run: gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${GITHUB_REF_NAME}"Write the workflow and the skill first. Then stop, once, at the only step a person must do. Give the user the exact instruction:
Go to Settings → Secrets and variables → Actions → New repository secret. Name:
UPLOAD_TO_ISSUE_SECRET_KEY. Value: the secret access key for<destination>.
Wait for confirmation. Do not push before this. If you push first, the smoke test fails for a reason that has nothing to do with the mechanism, and you will debug the wrong thing.
Tell the user one more thing at this point: upload-to-issue/base is pushed
directly, not through a pull request. An orphan branch cannot arrive any other
way. If a ruleset blocks branch creation, that is where it will show.
Five things break this silently, and none of them is visible in the code: Actions
disabled for the repository, a GITHUB_TOKEN restricted to read-only, a ruleset that
blocks the branch, a misspelled secret name, and an object ACL that makes every link 403. One end-to-end run catches all five.
Pick the target in this order:
- You already have a pull request open and a real file to attach. Use them. The smoke test and the delivery are the same act.
- You have no work in flight. Open the pull request that adds the skill
(§3b), then test on it with a small file — a few KB, because of fact 4. The
workflow is already live on
upload-to-issue/base, so this works before that pull request merges. - The user does not want a pull request. Open a throwaway issue, test on it, close it afterwards.
Then run it:
git fetch origin upload-to-issue/base
git checkout -b upload-to-issue/<N> origin/upload-to-issue/base
cp <file> .
git add <file> && git commit -m "Upload <file> to #<N>"
git push origin upload-to-issue/<N>
Report the mechanism installed only after all four of these hold:
- the comment appeared on issue/PR
<N>; curl -sI <link>returns200and acontent-typethat matches the file;git ls-remote origin upload-to-issue/<N>is empty — the branch was deleted;- the file opens and is not corrupt.
If any of them fails, say which one, and do not report success. If the push itself is blocked, stop and hand the file to the user with its local path.