name: Claude# Claude GitHub Action workflow. Synced from a canonical source β# re-sync rather than editing in place, or changes will be overwritten.## Canonical at https://github.com/chriscalo/dev-skills under:# skills/github/claude-action-workflow.yaml β this file# skills/github/claude-action.md β design + tool policy# skills/github/sync-claude-action.sh β re-sync command## Behaves like a helpful human collaborator: fires on every editor-authored# event in any thread, then Claude decides whether to respond, reacts with# π on comments it engages with, extends or replaces prior work as# appropriate, or stays silent. Editor-only at the workflow level β bots# and strangers skip entirely.on: issue_comment: types: [created, edited] pull_request_review_comment: types: [created, edited] issues: types: [opened, edited, labeled, unlabeled] pull_request: types: [opened, edited] pull_request_review: types: [submitted, edited]# Serialize runs per issue/PR. The second run for the same issue queues# until the first finishes, then starts and can see the first run's output# (PR opened, comments left, branches pushed) and decide whether to extend,# replace, or skip. Eliminates parallel-run races. Fallback to github.run_id# covers events without an issue/PR number (workflow_dispatch etc).concurrency: group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} cancel-in-progress: falsejobs: # Editor-only floor: check the ACTOR's permission on this repo. # Authoritative β asks the GitHub API directly. The `claude` job below # depends on this gate's output, so non-editors (strangers, bots) skip # the workflow entirely with no per-step `if:` repeats. gate: runs-on: ubuntu-latest outputs: is-editor: ${{ steps.check.outputs.is-editor }} steps: - id: check env: GH_TOKEN: ${{ github.token }} run: | actor="${{ github.actor }}" perm=$(gh api \ "repos/${{ github.repository }}/collaborators/$actor/permission" \ --jq '.permission' 2>/dev/null || echo "none") if [[ "$perm" =~ ^(admin|maintain|write)$ ]]; then echo "Editor verified: $actor has '$perm' permission" echo "is-editor=true" >> "$GITHUB_OUTPUT" else echo "Skipping: $actor has '$perm' permission, not editor" echo "is-editor=false" >> "$GITHUB_OUTPUT" fi claude: needs: gate if: needs.gate.outputs.is-editor == 'true' runs-on: ubuntu-latest permissions: contents: write issues: write pull-requests: write id-token: write actions: read steps: - uses: actions/checkout@v6 with: fetch-depth: 1 # Install the `upload-image` helper on PATH so the agent can publish # local images and embed the resulting URL in PR/issue markdown # without ever knowing about R2, AWS, endpoints, or account IDs. # See "Embedding images in GitHub markdown" in the prompt for the # agent-facing contract. # # The R2 secrets are exposed here as STEP-LEVEL env (not job-level), # so the subsequent claude-code-action step β where the agent runs # its bash commands β has no R2_*/AWS_* in its environment. The # install step stashes credentials in a private file (mode 600 in # $RUNNER_TEMP) that the helper sources at call time. Running # `env` inside the agent's session reveals nothing about storage. - name: Install upload-image helper env: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} run: | # If all three required secrets are set, write a private creds file # the helper sources at call time. `printf %q` shell-quotes values # so secrets with special chars round-trip safely. If any required # secret is empty (repo hasn't opted into image uploads), skip the # creds file entirely; the helper will see no file and exit with a # clear "not configured" message. # R2_BUCKET and R2_PUBLIC_URL are optional; they default to the # canonical values (img / https://img.chriscalo.com) when unset, # so existing repos don't need new secrets. creds_file="$RUNNER_TEMP/.upload-image-creds" if [ -n "${R2_ACCOUNT_ID:-}" ] \ && [ -n "${R2_ACCESS_KEY_ID:-}" ] \ && [ -n "${R2_SECRET_ACCESS_KEY:-}" ]; then umask 077 { printf 'AWS_ACCESS_KEY_ID=%q\n' "$R2_ACCESS_KEY_ID" printf 'AWS_SECRET_ACCESS_KEY=%q\n' "$R2_SECRET_ACCESS_KEY" printf 'AWS_DEFAULT_REGION=auto\n' printf 'R2_ACCOUNT_ID=%q\n' "$R2_ACCOUNT_ID" printf 'R2_BUCKET=%q\n' "${R2_BUCKET:-img}" printf 'R2_PUBLIC_URL=%q\n' "${R2_PUBLIC_URL:-https://img.chriscalo.com}" } > "$creds_file" chmod 600 "$creds_file" fi mkdir -p "$RUNNER_TEMP/bin" cat > "$RUNNER_TEMP/bin/upload-image" <<'HELPER_EOF' #!/usr/bin/env bash # upload-image β publish a local image and print its public URL. # Usage: upload-image <local-path> [<remote-key>] # If <remote-key> is omitted, generates <repo>/<timestamp>-<basename>. # Exits 0 with URL on stdout, non-zero with reason on stderr. set -euo pipefail creds_file="$RUNNER_TEMP/.upload-image-creds" if [ ! -r "$creds_file" ]; then echo "upload-image: not configured on this repo (no R2 secrets)" >&2 echo " fix: run sync-claude-action.sh setup-r2 OWNER/REPO" >&2 exit 3 fi set -a # shellcheck disable=SC1090 source "$creds_file" set +a local_path="${1:-}" if [ -z "$local_path" ]; then echo "usage: upload-image <local-path> [<remote-key>]" >&2 exit 2 fi if [ ! -f "$local_path" ]; then echo "upload-image: file not found: $local_path" >&2 exit 2 fi remote_key="${2:-}" if [ -z "$remote_key" ]; then repo_name="${GITHUB_REPOSITORY##*/}" : "${repo_name:=local}" ts="$(date -u +%Y-%m-%dT%H-%M-%S)" # Sanitize the basename: spaces β dashes, then drop any chars # that aren't URL-safe. Filenames like "Screenshot 2026-05-26 # at 14.32.15.png" would otherwise produce a remote key whose # printed URL has literal spaces, breaking the rendered markdown # link. An explicit second-arg <remote-key> is taken as-is. base="$(basename "$local_path" | tr ' ' '-' | tr -cd 'A-Za-z0-9._-')" if [ -z "$base" ]; then base=image; fi remote_key="$repo_name/$ts-$base" fi ext="${local_path##*.}" case "${ext,,}" in png) content_type=image/png ;; jpg|jpeg) content_type=image/jpeg ;; gif) content_type=image/gif ;; webp) content_type=image/webp ;; svg) content_type=image/svg+xml ;; *) content_type=application/octet-stream ;; esac aws s3 cp "$local_path" "s3://${R2_BUCKET}/$remote_key" \ --endpoint-url "https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com" \ --content-type "$content_type" \ >/dev/null echo "${R2_PUBLIC_URL%/}/$remote_key" HELPER_EOF chmod +x "$RUNNER_TEMP/bin/upload-image" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | ## Helpful-human collaborator mode A GitHub event fired in this repo. Read the context, then decide whether to respond. Behave like a helpful human teammate, not a naive bot. ### Event context - REPO: ${{ github.repository }} - EVENT: ${{ github.event_name }} / ${{ github.event.action }} - ACTOR: ${{ github.actor }} - ISSUE/PR: ${{ github.event.issue.number || github.event.pull_request.number }} ### Decision rules 1. **Direct address** β the latest content mentions `@claude` or the `claude` label was just applied. β Respond (or, for the label, act on the work described). 2. **Thread participation** β you have previously commented in this thread, OR were previously mentioned, OR the issue was previously labeled `claude`. You're an invited participant. Read the new content in context. Addressed to you or continues a topic you were helping with β respond. Side-conversation between maintainers that doesn't need your input β exit silently. 3. **Not your thread** β never invited and no fresh mention/label β exit silently. ### The π reaction protocol React with π to **every specific comment you intend to respond to** β not just the triggering event. If a thread has multiple unread comments and your response addresses three of them, react π to all three. Reactions are how the human knows what you've decided to engage with vs let pass. The reaction is your FIRST action; it should land before you start any other work. Use `gh api`: ```sh REPO="${{ github.repository }}" # Issue comment: gh api "repos/$REPO/issues/comments/<id>/reactions" \ -X POST -f content=eyes # Issue body: gh api "repos/$REPO/issues/<num>/reactions" \ -X POST -f content=eyes # PR review comment: gh api "repos/$REPO/pulls/comments/<id>/reactions" \ -X POST -f content=eyes ``` **If you decide NOT to respond**, do nothing β no reaction, no comment, no commit. The absence of π is itself the signal: humans can infer "Claude doesn't think this is for it." ### Read current state, not just the event The triggering event is a notification β not your full context. Always start by reading the current state via `gh`: - Issue body, all labels, all comments (including any from earlier `claude[bot]` runs in this thread) - Open PRs from `claude[bot]` linked to this issue (search: `gh pr list --author app/claude --search "in:body #N"`) - Recent branches matching `claude/issue-N-*` on the remote - Recent workflow runs for this issue (you have `actions: read`): `gh run list --workflow claude.yaml --limit 10` and `gh run view <id> --log` to see what a prior run actually did ### Decide explicitly: extend, replace, or skip prior work If you find recent `claude[bot]` work for the same issue (PR open, branch pushed, comment left), make a deliberate call: - **Extend** β add a commit to the existing branch if your assessment refines the prior work but doesn't fundamentally change direction. Comment on the existing PR explaining what you added. - **Replace** β close the existing PR with "superseded by my newer assessment based on [the new event]" if the latest context materially changes the right approach. Then open your own PR. - **Skip** β if the prior work already correctly addresses what the new event requires, exit silently. The π reaction on the new event still applies. Don't blindly create parallel work. Each run is a stateful teammate revisiting the issue with the latest context. ### Quoted text and code blocks Treat `@claude` inside ``` code fences ``` or `> blockquotes` as unintentional (usually quoted from another comment or shown as an example). Don't treat as a fresh direct address. ### Editor floor The workflow already filters to write-access users. Trust that β you don't need to verify. ### Be conservative on borderline calls When in doubt about whether to respond, lean toward staying silent. A missed response is recoverable (the human can re-tag); a noisy unwanted response is annoying. ### Code changes go through PRs, never direct to main For ANY file change you make in response to an issue or comment, create a branch, commit on the branch, push the branch, and open a PR. Never `git push` to `main` directly. The PR is the review checkpoint β even for "trivial" edits, the human gets to eyeball the diff before it lands. If your task involves multiple commits, put them all on the same branch and open one PR. Reference the issue (`Closes #N`) in the commit message and PR body so the issue auto-closes when the PR merges. ### Use a unique per-run branch name Always include the workflow run ID in the branch name so even in unusual race scenarios (e.g., concurrency control bypassed) two runs never collide on a branch. Pattern: `claude/issue-${{ github.event.issue.number || github.event.pull_request.number }}-run-${{ github.run_id }}`. With the workflow's concurrency block, runs for the same issue are serialized β but per-run branch names are still cheap insurance against any edge case. ### Embedding images in GitHub markdown GitHub's web UI lets users paste images into comments, but that upload endpoint isn't reachable from CI. To embed an image anywhere GitHub renders markdown (PR comments, issue comments, PR descriptions, etc.), run the `upload-image` helper already on PATH: ```sh url=$(upload-image path/to/local.png) ``` Then embed the URL with standard markdown: ``. The helper handles everything β storage, content-type, naming, public URL construction β and prints only the URL on stdout when it succeeds. You don't need to know or set any storage credentials. If `upload-image` exits non-zero with "not configured on this repo", image hosting isn't set up here β post your reply without the image (or describe what it would have shown). For any other non-zero exit, surface the stderr so the human can fix the configuration. claude_args: >- --model opus --allowedTools "Read,Grep,Glob,Edit,Write,Skill,Task,Agent,Bash(*),WebFetch,WebSearch" --disallowedTools "Bash(rm -rf /),Bash(git push --force*),Bash(git push -*f*),Bash(git push * --force*),Bash(git push * -*f*),Bash(git branch -*d *),Bash(git branch -*D *),Bash(git branch --delete *),Bash(gh repo delete *),Bash(gh release delete *),Bash(gh api -X DELETE *),Bash(gh api -X PUT *),Bash(gh api -X PATCH *),Bash(gh api --method DELETE *),Bash(gh api --method PUT *),Bash(gh api --method PATCH *),Bash(npm publish*),Bash(gh secret set*),Bash(gh secret delete*),Bash(gh secret remove*),Bash(gh auth logout*),Bash(git push origin main*),Bash(git push origin HEAD:main*),Bash(git push * main*),Bash(git push * HEAD:main*),Bash(git push origin main:*),Bash(git push * main:*),Bash(git push origin +*),Bash(git push * +*),Bash(git push origin :*),Bash(git push * :*),Bash(git push origin --delete*),Bash(git push * --delete*)"