Skip to content

Add production Express server (dist/ + dev/prod parity) #66

Add production Express server (dist/ + dev/prod parity)

Add production Express server (dist/ + dev/prod parity) #66

Workflow file for this run

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: >-
--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*)"