raw
yaml
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: false

jobs:
  # 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:
            `![alt text]($url)`. 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*)"