Add production Express server (dist/ + dev/prod parity) #66
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | |
| ``. 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: >- | |
| --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*)" |