Skip to content

Improve narrative flow and math derivations in count regression chapter #4061

Improve narrative flow and math derivations in count regression chapter

Improve narrative flow and math derivations in count regression chapter #4061

Workflow file for this run

name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
# Serialize @claude sessions per issue/PR. Multiple rapid @claude
# comments used to fire concurrent runs that all tried to push to the
# same branch; the action's git-push.sh refuses non-fast-forward
# pushes, so the loser-on-the-race silently lost its work (see PR #706
# 2026-05-19 18:31–18:39 window, where 2 of 4 races were blocked).
# `cancel-in-progress: false` means each new comment QUEUES behind the
# running session rather than starting a competing one.
#
# Known limitation — queued runs may re-handle absorbed comments:
# if comment 2 arrives while run A (triggered by comment 1) is
# active, run A's polling step (see `prompt:` below) addresses
# comment 2 in-session — but run B is also queued and will still
# start once A finishes, re-addressing comment 2 as its triggering
# event. In practice this costs at most an extra commit or a
# no-op session; the tasks `@claude` performs here are idempotent
# enough that the cost is acceptable. The alternative
# (`cancel-in-progress: true`) would throw away in-flight work,
# which is worse than the occasional duplicate.
concurrency:
# `github.event.pull_request.number` is already populated for
# `pull_request_review` and `pull_request_review_comment` events, so
# the two issue/PR-number expressions below cover all four trigger
# types. The `|| github.run_id` tail is a defensive fallback: if
# both numbers are ever undefined (shouldn't happen with the four
# `on:` triggers above), the group would otherwise collapse to
# `claude-`, serializing every session globally. Falling back to
# `run_id` keeps the unknown case from blocking unrelated PRs.
# A stray `github.event.review.pull_request_url` fallback that lived
# here previously was dropped: it's a full API URL string, not a
# number, so it would have produced a different group than the
# `claude-N` used by `issue_comment` events on the same PR.
group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
jobs:
claude:
# Only a HUMAN @claude mention should invoke the agent. Skip events
# whose sender is a bot, otherwise the agent self-triggers: a comment
# posted by claude[bot] (the agent's own GitHub App identity) that
# merely *contains* `@claude` — quoting a prior `@claude ...` command,
# or a "posted by @claude" signature — re-fires this workflow, which
# posts another such comment, which re-fires it again, unbounded
# (observed on PR #900, 2026-06-16). The `Acknowledge @claude mention`
# ack below is posted via GITHUB_TOKEN (github-actions[bot]), and
# GITHUB_TOKEN-authored events cannot trigger workflows — but claude[bot]
# comments are posted with the Claude GitHub App token, which DOES fire
# `issue_comment`, so guarding on the token kind is not enough; we guard
# on the author. `github.event.sender` is the actor that raised the
# event (comment/review author or issue opener) for all four trigger
# types; this mirrors the `sender.type != 'Bot' && !endsWith(actor,
# '[bot]')` double guard in claude-code-review.yml. The second clause
# is belt-and-suspenders: it also blocks a bot actor whose
# `sender.type` is somehow not reported as 'Bot' (old/misconfigured
# integrations), and keeps the two workflows idiomatically identical.
if: |
github.event.sender.type != 'Bot' && !endsWith(github.actor, '[bot]') && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
# `write` (not `read`) is required: besides letting Claude read CI
# results on PRs, the post-Claude steps below call `gh workflow run
# claude-code-review.yml` (the workflow_dispatch REST endpoint
# `POST /actions/workflows/{id}/dispatches`), which GITHUB_TOKEN can
# only reach with `actions: write`. With `actions: read` every
# dispatch 403s ("Resource not accessible by integration") and is
# swallowed by the `|| echo "::warning::"` fallback, so the
# code-review never auto-runs (observed on PR #900, run
# 27598978484: the @claude-review dispatch 403'd silently). `write`
# is a superset of `read`, so CI reads still work.
actions: write
# Expose the ucdavis/epi202 and ucdavis/epi204 fine-grained PATs to
# every step in this job, including the Claude action's subprocess.
# See .github/copilot-instructions.md ("Accessing the private
# ucdavis/epi202 repository" and "Accessing the private ucdavis/epi204
# repository") for usage. Empty if the secret isn't set, in which case
# the variable is simply unavailable to Claude — no error.
env:
EPI202_TOKEN: ${{ secrets.EPI202_TOKEN }}
EPI204_TOKEN: ${{ secrets.EPI204_TOKEN }}
steps:
# Post a visible acknowledgment on the triggering PR/issue BEFORE the
# multi-minute apt/R/Quarto/renv setup chain begins. Without this, a
# failure during setup (see the renv auth issue fixed in #777) leaves
# the user with no signal that the @claude mention was even received.
# `continue-on-error: true` keeps a transient comment-API hiccup from
# killing the whole workflow. The late-comment polling step in the
# `Run Claude Code` prompt below filters comments where
# `user.type == "Bot"`, so this ack — posted by github-actions[bot]
# via GITHUB_TOKEN — does not register as a new @claude request and
# cannot trigger a self-loop.
- name: Acknowledge @claude mention
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUM: ${{ github.event.issue.number || github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue comment "$NUM" --repo "${{ github.repository }}" \
--body ":eyes: Picked up by [workflow run #${{ github.run_id }}]($RUN_URL). R/Quarto/renv setup runs first (~3-5 min); Claude itself responds after that."
# zizmor flags the persisted credential here (artipacked). Keeping it,
# deliberately: the @claude agent commits to the branch and a later step
# pushes it, so the credential is load-bearing. This job uploads no
# artifact, which is the leak vector artipacked is actually about.
- name: Checkout repository # zizmor: ignore[artipacked]
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Checkout submodules
run: |
git config --global url."https://x-access-token:${{ secrets.SUBMODULES_TOKEN }}@github.com/".insteadOf "https://github.com/"
git submodule update --init --recursive --depth 1
# Install system dependencies required for R packages and tools
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
jags \
libcurl4-openssl-dev \
libssl-dev \
libxml2-dev \
libfontconfig1-dev \
libharfbuzz-dev \
libfribidi-dev \
libfreetype6-dev \
libpng-dev \
libtiff5-dev \
libjpeg-dev \
libglpk-dev \
poppler-utils \
tesseract-ocr \
tesseract-ocr-eng \
maxima \
python3-pip
- name: Install SymPy (Python CAS)
run: pip3 install --break-system-packages sympy
- name: Set up Pandoc
uses: r-lib/actions/setup-pandoc@v2
- name: Set up R
uses: r-lib/actions/setup-r@v2
with:
r-version: 'release'
use-public-rspm: true
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
with:
tinytex: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install R dependencies via renv
uses: r-lib/actions/setup-renv@v2
env:
# Without GITHUB_PAT, renv can't hit the GitHub API to resolve
# GitHub-sourced packages (e.g. `ddsjoberg/gtsummary`) and
# `renv::restore()` aborts with "GitHub authentication
# credentials are not available." Matches publish.yml.
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
with:
cache-version: 1
- name: Capture PR head SHA before Claude
id: head_before
if: github.event.pull_request.number || github.event.issue.pull_request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
# Create a feature branch for issue triggers so Claude's commits
# land somewhere durable.
#
# Background: claude-code-action runs in `agent` mode here (any time
# `prompt:` is set, which we do below for the late-comment polling
# loop). Agent mode skips the action's built-in setup-branch step
# and its post-session `git-push.sh` wrapper — the machinery that
# tag mode uses to create `claude/issue-N-<timestamp>`, push, and
# link a PR back from the triggering issue. Without that machinery,
# an `@claude` mention on an issue leaves Claude editing files on
# the ephemeral `main` checkout; the commits are discarded at
# runner cleanup and no PR ever appears (see issue #786 history
# 2026-05-22: two consecutive @claude runs both produced zero
# branch and zero PR despite Claude believing it had pushed).
#
# The paired "Push branch and open draft PR for issue trigger"
# step below pushes this branch and opens the PR. PR-trigger runs
# already work today (Claude commits to the PR's own checkout
# branch and the existing post-step at the bottom of this file
# re-requests review when the head SHA changes), so this only
# fires for issue/issue-comment-on-issue events.
- name: Set up branch for issue trigger
id: issue_branch
if: |
github.event_name == 'issues' ||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request)
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
BRANCH="claude/issue-${ISSUE_NUMBER}-$(date -u +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "starting_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "Created and switched to $BRANCH"
# Build a WebFetch permission list from the shared stats-allowlist repo
# so Claude can read pages from d-morrison.github.io and other approved
# sites referenced in the book. Trust model: anyone with push access to
# d-morrison/stats-allowlist@main can expand Claude's WebFetch scope on
# the next workflow run here. The hostname regex below ensures even a
# tampered entry can only widen WebFetch coverage, not inject other
# claude_args.
- name: Build allowed-tools list from stats-allowlist
id: tools
run: |
# Initialize to empty so a curl failure degrades gracefully to the
# base toolset (Bash only) rather than failing the whole @claude
# workflow with no PR feedback.
: > /tmp/allowlist.txt
curl -fsSL https://raw.githubusercontent.com/d-morrison/stats-allowlist/main/allowlist.txt \
-o /tmp/allowlist.txt \
|| echo "::warning::Could not fetch stats-allowlist; running Claude without WebFetch permissions."
# Reject anything that isn't a strictly valid hostname so a typo or
# tampered upstream entry can't inject extra claude_args via " or
# other shell metacharacters.
WEBFETCH=$(tr -d '\r' < /tmp/allowlist.txt \
| grep -v '^[[:space:]]*$' \
| grep -v '^[[:space:]]*#' \
| sed -E 's#^[[:space:]]*https?://##' \
| sed -E 's#/.*$##' \
| sed -E 's#[^A-Za-z0-9.-]+$##' \
| tr '[:upper:]' '[:lower:]' \
| grep -E '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' \
| sort -u \
| sed 's#.*#WebFetch(domain:&)#' \
| paste -sd, -)
# `Bash(gh api repos/<this-repo>/*)` is needed by the
# polling-for-new-comments prompt below: the session checks
# for late-arriving @claude comments before declaring done,
# so multi-comment bursts get handled in a single session
# instead of racing parallel runs.
#
# The pattern is scoped to the current repository's API
# surface, which covers the polling step's endpoints
# (`repos/<repo>/issues/N/comments`, `repos/<repo>/pulls/N/comments`,
# `repos/<repo>/pulls/N/reviews`) and the routine read-only
# session uses (fetching file SHAs, reading PR metadata,
# checking branch contents). It excludes cross-repo and
# cross-org `gh api` calls (`/user`, `/orgs/...`, `/repos/OTHER/...`)
# that the session doesn't need.
#
# Within the repo scope the pattern still matches write
# endpoints (`gh api -X POST/PATCH/DELETE repos/<repo>/...`),
# not just reads, because allowed-tools doesn't support
# HTTP-method filtering. The ultimate bound on what's
# possible is `GITHUB_TOKEN`'s scopes (`contents: write`,
# `pull-requests: write`, `issues: write`), not this
# allowlist.
#
# The `gh pr/issue/run` read-only CLI patterns below mirror
# the committed `.claude/settings.json` allowlist so the CI
# agent and local sessions grant the same inspection commands
# (these CLI subcommands hit the same API as `gh api` but
# under different command strings, so the `gh api repos/*`
# entry alone doesn't cover them). Kept to `view`/`list`
# (read-only) — no `gh pr merge`/`close`/`edit` etc.
GH_TOOLS="Bash(gh api repos/${{ github.repository }}/*),Bash(gh pr view *),Bash(gh pr list *),Bash(gh issue view *),Bash(gh issue list *),Bash(gh run view *),Bash(gh run list *)"
# Git commands Claude needs to commit work in agent mode.
# In tag mode the action auto-injects these; in agent mode
# (which we're in whenever `prompt:` is set) it doesn't, and
# without them Claude's `git commit` calls get denied — that
# was the root cause of the 24 `permission_denials_count` in
# issue #786's runs (Claude believed it committed but every
# commit attempt was actually blocked).
#
# Several operations are deliberately omitted from this list:
# - `git push`: the post-Claude step pushes, and keeping push
# out of Claude's hands avoids races with that step and
# prevents accidental pushes to weird branches.
# - `git checkout` for branch-switching: only the
# file-restoration form (`git checkout -- <file>`) is
# allowed. If Claude switched off the issue branch the
# pre-step prepared, the post-step's `git rev-parse HEAD ==
# STARTING_SHA` check would compare against the wrong tip
# and silently skip the push, losing Claude's work without
# warning. Pinning Claude to the branch we set up is
# safer than trying to detect-and-recover after the fact.
# - `git branch`: the broad `Bash(git branch*)` form also
# matches `git branch -D` (delete) and `git branch -f`
# (force-move). Claude doesn't need branch management in
# this workflow — the pre-step puts it on the right branch
# and the prompt tells it to stay there — and `git status`
# (allowed below) already reports the current branch name,
# covering the only legitimate read-only use case.
GIT_TOOLS="Bash(git add *),Bash(git commit *),Bash(git rm *),Bash(git mv *),Bash(git status*),Bash(git diff*),Bash(git log*),Bash(git show*),Bash(git restore *),Bash(git checkout -- *)"
# File-editing tools. claude-code-action ALREADY allows file
# reads/edits by default (see the action's configuration docs:
# "By default, Claude only has access to: File operations
# (reading, committing, editing files, ...)"), and `claude_args
# --allowed-tools` is ADDITIVE to that default rather than a
# replacement — which is why prior @claude runs committed file
# edits fine (e.g. claude[bot] commit 05318e02 on PR #843).
#
# We still list the file tools explicitly because a session
# that inspects its own --allowed-tools string and sees only
# Bash/gh/git/WebFetch entries can wrongly conclude file
# editing is disallowed and bail out — posting its diff as a
# comment instead of committing it (PR #843 run 27032044840,
# 2026-06-05: a 27-min session designed probability-based
# example rewrites, then declined to apply them claiming
# "Edit/Write require approval not configured in the current
# session's --allowed-tools list"). Naming the tools here makes
# the grant unambiguous in the string the agent can see, and
# guards against the action's additive behaviour ever changing.
# Read/Glob/Grep are listed alongside the write tools so the
# same allowlist-inspection logic can't talk a session out of
# reading or searching files either (they're read-only and,
# like the edit tools, already allowed by default).
FILE_TOOLS="Read,Glob,Grep,Edit,Write,MultiEdit"
if [ -n "$WEBFETCH" ]; then
echo "allowed=Bash(Rscript *),Bash(quarto *),Bash(curl *),${FILE_TOOLS},${GH_TOOLS},${GIT_TOOLS},${WEBFETCH}" >> "$GITHUB_OUTPUT"
else
echo "allowed=Bash(Rscript *),Bash(quarto *),Bash(curl *),${FILE_TOOLS},${GH_TOOLS},${GIT_TOOLS}" >> "$GITHUB_OUTPUT"
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Install the Morrison-Lab/ai-config plugin so this agent gets the
# shared skills, commands, and hooks.
#
# Both inputs are required: adding a marketplace alone installs
# nothing. Per Claude Code's team-marketplace docs, "as of v2.1.195,
# adding the marketplace doesn't install plugins that come from an
# external source, on any path that loads plugins" — so declaring
# the marketplace in .claude/settings.json is NOT sufficient here,
# and a non-interactive CI run can never hit the trust prompt that
# mechanism depends on.
#
# This mirrors the reviewer's copy in Morrison-Lab/gha's
# .github/actions/run-claude-review-attempt/action.yml, which spells
# the same URL and plugin ref out for the same reason. Update both
# sites together.
plugin_marketplaces: |
https://github.com/Morrison-Lab/ai-config.git
plugins: |
ai-config@Morrison-Lab
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Pick up late-arriving @claude comments before declaring done.
# Background: until the workflow's `concurrency:` block was added,
# rapid @claude comments fired concurrent runs that raced to push
# to the same branch; the action's git-push.sh refuses non-
# fast-forward pushes, so the losers silently lost their work
# (PR #706 2026-05-19 18:31–18:39). The concurrency block now
# serializes runs, but a long-running session can still miss a
# comment posted while it's working. This prompt has Claude
# explicitly poll for newer @claude mentions and address them
# in the same session before finishing.
#
# claude-code-action behaviour note: when `prompt:` is set, the
# action APPENDS it (wrapped in `<custom_instructions>`) to the
# default tag-mode prompt — it does NOT replace the default.
# The default prompt already provides the triggering content via
# `<trigger_comment>` (or `<pr_or_issue_body>` for `issues`
# events) plus the formatted PR/issue body, comments, review
# comments, and changed files. The instructions below therefore
# only need to describe the polling behaviour; the request
# itself is already visible to Claude.
# (verified against
# https://github.com/anthropics/claude-code-action/blob/main/src/create-prompt/index.ts —
# see `generatePrompt`'s `if (context.githubContext?.inputs?.prompt)` branch.)
prompt: |
You were triggered by an @claude mention in
${{ github.event_name }}. Address the request in that
comment/issue/review.
**If your reply is prose (a question answered, a
recommendation, a design discussion) rather than a code
change**, write your final assistant message as if you
were posting a reply on the triggering PR/issue thread —
because that's what happens: a post-Claude workflow step
takes your last assistant message and posts it back to
the source thread when you didn't commit any code. (When
you DO commit code, the commits themselves are the
deliverable and the post-step skips posting your text to
avoid noise.) Don't write it as an internal log
("Investigated foo, found bar, will do baz") — write it
as the actual reply you want the requester to read. When the
trigger was an inline review comment on the diff (a
`pull_request_review_comment`), your reply is posted as a threaded
reply to that exact comment — keep it focused on the line(s) that
comment is about.
**Before declaring the task complete, check for additional
@claude requests that arrived after the triggering event.**
The three event types that trigger this workflow each live at
a different GitHub API endpoint, and `@claude` can appear in
any of them. Poll the endpoints that apply to your trigger
type before finishing.
This run was triggered by a `${{ github.event_name }}` event.
Pre-resolved context (you don't need to figure these out yourself):
- PR context? `${{ (github.event.pull_request.number || github.event.issue.pull_request) && 'yes' || 'no' }}`
(`issue_comment` fires for both issues and PRs; this
flag disambiguates without you guessing.)
- Entity number: `${{ github.event.issue.number || github.event.pull_request.number }}`
- Session-start SHA (the tip of the branch when your
session started): `${{ steps.head_before.outputs.sha || steps.issue_branch.outputs.starting_sha }}`
For PR triggers this is the PR head before you ran; for
issue triggers it's the tip of the freshly-created
`claude/issue-N-<timestamp>` branch. Use it as the base
for the step-5 commit-presence check below.
- **PR triggers** (`issue_comment` on a PR,
`pull_request_review`, `pull_request_review_comment`):
poll all three endpoints listed below.
- **Issue triggers** (`issue_comment` on an issue,
`issues`): poll **only** `/issues/N/comments`; the
`/pulls/...` endpoints don't apply, and the issue body
itself doesn't admit late-arriving `@claude` content
(there's typically nothing else to poll anyway).
Endpoints:
- `/issues/N/comments` — PR/issue **timeline** comments
(covers `issue_comment` events; this endpoint does NOT
contain the issue body itself, which lives at
`/issues/N` and is already supplied to you by the
action's default prompt — no need to re-fetch).
- `/pulls/N/comments` — **inline review-thread** comments
on a PR (covers `pull_request_review_comment`).
- `/pulls/N/reviews` — PR **review submissions**; the
`body` field here is where `@claude` lives for a
`pull_request_review` trigger.
1. Fetch each endpoint (skip the `/pulls/...` ones for
`issues` triggers — the GitHub-Actions expression
`${{ github.event.issue.number || github.event.pull_request.number }}`
is already substituted in below):
(the URLs are NOT quoted — they contain no shell-special
characters after substitution, and the allowed-tools pattern
`Bash(gh api repos/<repo>/*)` only matches the unquoted form;
a leading quote like `gh api 'repos/...` fails the match and
the call is denied — see PR #806 comment 4539906359):
```
gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate
gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate # PRs only
gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/reviews --paginate # PRs only
```
2. From the combined response, identify entries where ALL of:
- the relevant timestamp (`created_at` for comments,
`submitted_at` for reviews) is strictly greater than
the triggering event's timestamp. The fallback chain
below picks the most-specific signal available for
each event type:
(`${{ github.event.comment.created_at || github.event.review.submitted_at || (github.event_name == 'issues' && github.event.action == 'opened' && github.event.issue.created_at) || github.event.issue.updated_at }}`)
Per the current `on:` triggers, that resolves to:
`comment.created_at` for `issue_comment` and
`pull_request_review_comment`; `review.submitted_at`
for `pull_request_review`; `issue.created_at` for
`issues:opened`; and the final `issue.updated_at`
fallback for `issues:assigned`. The chain is a
`||`-cascade, so any future event type added to
`on:` without matching logic will also land on
`issue.updated_at` — reassess this expression when
changing the trigger list.
- the relevant text field (`body` for comments and
reviews) contains `@claude`
- `user.type` is not `"Bot"` — this generically excludes
GitHub App accounts (claude[bot], github-actions[bot],
dependabot[bot], etc.) and prevents the polling loop
from being driven by an arbitrary bot that learns to
say `@claude`. A human who has somehow set their
`user.type` to `Bot` would also be excluded, but that
isn't a real scenario.
Note: for `issues` events the triggering timestamp falls
back to `issue.updated_at`, which is a coarse signal
(any update — assignment, label change, etc. — bumps it).
Comments posted in the same second as another issue
update could be missed; this is rare but not impossible.
3. If any matching entries exist, address each one in this
same session (in chronological order), then repeat from
step 1. Stop when no new @claude requests remain, or
after **at most 5 additional iterations of steps 1–3**
(i.e. the cap applies to the polling loop, not to the
initial task you were triggered for) — whichever comes
first. The cap prevents a pathological ping-pong
between a user (or two bots) and this session from
keeping the run alive indefinitely; if you hit the cap
with new comments still arriving, post a comment saying
so and exit, and the next workflow run will pick up
from there via the serialize-per-PR concurrency group.
4. **Commit all changes before finishing — this is the
single most common way these runs silently fail.**
Editing files with Edit/Write changes the working tree
but does NOT change the git history; only `git add` +
`git commit` does. If you describe work as "done" in a
comment but never committed, the post-step sees the
PR head SHA unchanged and your work is lost when the
runner is reclaimed. The user has no way to recover it.
`git add`, `git commit`, `git rm`, and read-only git
inspection commands are in your allowed-tools list;
`git push` is NOT, and any attempt to call it will
fail with a permission denial. **Do not attempt
`git push`** — a post-Claude workflow step performs
the push for you:
- For PR triggers, your commits go on the PR's head
branch — the post-step re-requests review and
dispatches code review when the head SHA moves.
- For issue triggers, the workflow pre-creates a
`claude/issue-N-<timestamp>` branch and checks it
out before your session starts; the post-step
pushes that branch and opens a draft PR linking
back to the issue. (Do not create a branch yourself
— one is already checked out for you. Do not try to
`gh pr create` yourself — the post-step does it.)
The concurrency group guarantees no parallel session
is racing yours, so the push should succeed unless
someone pushed by hand.
5. **Pre-finish self-check (mandatory).** Immediately
before you stop, run these and verify the output
(substitute the Session-start SHA from the context
block above for `<START>`):
```
git status --porcelain # MUST be empty
git log --oneline <START>..HEAD # check whether you committed
```
If `git status --porcelain` shows ANY output, you have
uncommitted edits — stage and commit them before
stopping. If you intended to make code changes but the
`git log <START>..HEAD` range is empty, you have NOT
done the task — the edits live only in the working
tree and will be discarded. Treat this as a task
failure: commit the work, or if you've decided not to
change anything, say so explicitly in your final
comment ("no changes needed because X") rather than
describing edits that weren't committed.
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# Allow the pre-commit checklist tools from CLAUDE.md (Rscript for
# lint/spell-check, quarto render for chapter previews), curl for
# downloading source PDFs too large for WebFetch (issue #740), the
# gh CLI for the polling step in the prompt above, plus WebFetch
# for every host in the shared stats-allowlist.
claude_args: '--allowed-tools "${{ steps.tools.outputs.allowed }}"'
# Fetch the PR's head SHA once, post-Claude, for the two
# downstream steps that both need it: the prose-post step's
# COMMITTED check and the "Re-request review" step's
# SHA_AFTER comparison. Without this shared step each would
# call `gh api pulls/$N` independently — two round-trips per
# PR-trigger run where one suffices. PR-context only (mirrors
# the "Capture PR head SHA before Claude" step above).
- name: Capture PR head SHA after Claude
id: head_after
if: always() && (github.event.pull_request.number || github.event.issue.pull_request)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
# Post Claude's final assistant message back to the source thread
# when no code was committed.
#
# Background: claude-code-action runs in `agent` mode whenever
# `prompt:` is set (we set it for the late-comment polling loop
# above). Agent mode skips the action's built-in machinery that
# tag mode uses to surface Claude's final response as a sticky
# GitHub comment — the action just runs the agent and assumes
# the workflow author handles output surfacing. We already added
# explicit pre/post steps for the analogous setup-branch +
# git-push gap (#788 / #794); this step closes the remaining
# gap for **prose** responses, where Claude's deliverable is
# text rather than commits and there's nothing else to surface.
# See PR #755 comment 4530859000 (2026-05-25): a math question
# ran a successful 60s / 16-turn Claude session that produced
# ~$0.19 of thinking and zero visible output, because the
# response lived in /home/runner/work/_temp/claude-execution-output.json
# until the runner tore down.
#
# When Claude HAS committed code, the commits themselves convey
# the work and the existing `Re-request review` / `Push branch
# and open draft PR` steps surface it; in that case posting
# Claude's running text on top would be noise — skipped via
# the COMMITTED check below.
- name: Post Claude's response if no code was committed
# The `steps.claude.outcome == 'success'` guard is deliberate:
# we only surface a *completed* session's response. A
# cancelled session (concurrency preemption — another run is
# taking over and will post its own response) or a timed-out
# one (partial, possibly-misleading output) is intentionally
# NOT posted. If surfacing partial output on cancellation
# ever proves useful, broaden to `!= 'skipped'` with a
# "(session interrupted)" footer.
#
# Skip on `@claude review` triggers that have PR context — the
# dispatch step below will fire claude-code-review.yml, which
# posts its own sticky review comment. Posting Claude's
# agent-session prose on top would double-comment the PR
# (the agent's internal "I'll review this PR" monologue, then
# the real review).
#
# The PR-context guard (`pull_request.number || issue.pull_request`)
# is load-bearing: without it, `@claude review` on a plain
# *issue* would skip the prose-post AND skip the dispatch
# (which has its own PR-context guard), losing the response
# entirely — the exact bug this PR fixes, re-introduced for
# this one trigger pattern. With the guard, plain-issue
# triggers fall through to the prose-post regardless of body.
#
# The `@claude review` predicate MUST stay in sync with the
# dispatch step's `if:` predicate. Note: `contains()` is
# case-sensitive, so `@Claude review` would not match here
# (or in the dispatch step). Standard GitHub `@`-mentions
# are lowercase so this matches the common case.
if: |
always() &&
steps.claude.outcome == 'success' &&
!(
(
contains(github.event.comment.body, '@claude review') ||
contains(github.event.review.body, '@claude review')
) &&
(github.event.pull_request.number || github.event.issue.pull_request)
)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENTITY_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
ISSUE_BRANCH_STARTING_SHA: ${{ steps.issue_branch.outputs.starting_sha }}
PR_HEAD_BEFORE: ${{ steps.head_before.outputs.sha }}
PR_HEAD_AFTER: ${{ steps.head_after.outputs.sha }}
run: |
# Did Claude commit anything? Two cases:
# - Issue triggers: the pre-step put us on a fresh
# `claude/issue-N-*` branch and recorded its tip in
# ISSUE_BRANCH_STARTING_SHA. Local HEAD vs. that SHA
# tells us whether Claude committed.
# - PR triggers: PR_HEAD_BEFORE captured the PR's remote
# head SHA pre-Claude; PR_HEAD_AFTER (from the shared
# "Capture PR head SHA after Claude" step) is the head
# now. If they differ, Claude pushed. (Local HEAD is
# `main` on PR triggers — actions/checkout pulls the
# default branch for `issue_comment` events — so the
# local-SHA check from the issue branch doesn't apply
# here.)
COMMITTED=false
if [ -n "$ISSUE_BRANCH_STARTING_SHA" ]; then
CURRENT_LOCAL=$(git rev-parse HEAD)
if [ "$CURRENT_LOCAL" != "$ISSUE_BRANCH_STARTING_SHA" ]; then
COMMITTED=true
fi
fi
# Require a non-empty PR_HEAD_AFTER: if the "Capture PR head
# SHA after Claude" step's `gh api` failed (transient 5xx,
# rate limit), its output is "". Without the `-n` guard,
# "" != "$PR_HEAD_BEFORE" would be true and we'd wrongly
# conclude Claude committed and SKIP posting the prose —
# the exact data-loss this step exists to prevent. Defaulting
# to "not committed" on an unknown AFTER SHA errs toward
# posting (safe) rather than swallowing (lossy).
if [ -n "$PR_HEAD_BEFORE" ] && [ -n "$PR_HEAD_AFTER" ] && [ "$PR_HEAD_AFTER" != "$PR_HEAD_BEFORE" ]; then
COMMITTED=true
fi
if [ "$COMMITTED" = "true" ]; then
echo "Claude committed code; commits are the deliverable, skipping response-text post."
exit 0
fi
# Locate the execution-output JSON. Prefer the action's
# exported step output; fall back to the known default path
# in case the action version changes the output name.
FILE="${EXECUTION_FILE:-/home/runner/work/_temp/claude-execution-output.json}"
if [ ! -f "$FILE" ]; then
echo "::warning::No Claude execution output found at $FILE; nothing to post."
exit 0
fi
# Extract the assistant events from the execution-output JSON.
#
# Format robustness: the action's execution_file is a single
# JSON array of event objects (`[{...},{...}]`), NOT
# newline-delimited JSON as an earlier version of this step
# assumed. With `jq -s` (slurp), a single-array file becomes
# `[[...]]`, so a bare `.[] | select(.type==...)` indexes the
# inner *array* with `.type` and dies with "Cannot index array
# with string" (see run 26417479726, the first real run of
# this step after #805 merged). `flatten(1)` collapses that
# outer wrap so we iterate event objects either way — and it
# also tolerates true NDJSON (slurps to a flat array,
# flatten is a no-op) and a stray array element. The
# `type=="object"` guard then skips any non-object entry
# before `.type` is accessed (jq's `and` short-circuits).
# The same `flatten(1) | select(type=="object" ...)` prelude
# is repeated in the RESPONSE filter below — keep them in sync.
# Sanity-check that the file actually contains assistant-typed
# events. If it doesn't, the likely cause is an upstream
# schema change (the action renaming the event type), not a
# genuinely empty session — surface that distinctly rather
# than silently posting nothing.
ASSISTANT_COUNT=$(jq -rs 'flatten(1) | [.[] | select(type == "object" and .type == "assistant")] | length' < "$FILE")
if [ "$ASSISTANT_COUNT" = "0" ]; then
echo "::warning::No assistant-typed events in $FILE — the action's output schema may have changed (expected .type == \"assistant\"). Nothing to post."
exit 0
fi
# Grab the LAST assistant message's text-typed content blocks
# (a single message may interleave text and tool_use blocks;
# we want only the prose).
#
# ...with one carve-out. Since the ai-config plugin started
# being installed here (#1076), the agent follows that
# corpus's flag-session-boundaries.md, which requires the
# LAST message it writes to be a `**Stopping Point**`
# declaration. This step also wants the last message. Two
# rules, one slot, and the declaration always wins because it
# is by construction written last -- so every prose reply was
# being replaced by a one-line status marker and the actual
# answer was discarded, unrecoverably (the run log does not
# carry the conversation, and no execution-file artifact is
# published). See #1081; the run that first diagnosed this
# had its own diagnosis swallowed the same way.
#
# So: when the final message leads with such a declaration, post
# everything from the last substantive message onward, keeping
# both. A message that merely *ends* with a stopping-point line
# does not lead with one, so it still posts unchanged.
#
# The selection is a SLICE-AND-JOIN rather than a pick, and that
# is the point. Any rule for "is this message just a
# declaration?" will misjudge some message, and the two ways of
# being wrong are not symmetric: including a message that did not
# need including costs some redundant text, visibly, whereas
# excluding one that did costs the answer, silently and
# unrecoverably. Joining the whole tail can only ever add text,
# never drop it, so a misjudgement degrades into noise instead of
# data loss. Do not "optimize" this back into picking one message.
#
# This was briefly two deliberately different tests. It is now
# exactly ONE, `is_decl`, a prefix match, used for both jobs:
#
# - gating whether to look back at all. A false positive here
# starts a slice at this same message; a false negative posts
# the declaration alone and drops the answer behind it.
# - choosing where the slice STARTS. A message that looks like a
# declaration is never chosen as the start, so a false
# positive only moves the start EARLIER and includes more.
#
# An earlier revision refined the second job with "single
# paragraph, or under 400 characters", to trim redundant leading
# context. That was a mistake, and an instructive one: it made the
# test non-eager, so a long multi-paragraph declaration failed it,
# got treated as substantive, became the slice start, and excluded
# a real answer sitting behind it -- the exact loss this design is
# built to prevent, reintroduced by an optimization for tidiness.
# A single eager test is what makes "the slice only ever grows"
# actually true rather than merely intended. Any refinement here
# must preserve that property, which means it may only ever match
# MORE messages, never fewer.
#
# The marker tolerates leading blockquote, heading, and list
# decoration (including numbered lists) and makes the bold
# markers optional. The convention spells it `**Stopping Point**`,
# but `> **Stopping Point**`, `## Stopping Point`, and
# `1. **Stopping Point**` are the same declaration, and failing to
# recognize one reproduces exactly the bug this fixes.
#
# The slice steps over a RUN of declarations, not just one,
# because the polling loop in the prompt above can produce a
# second declaration right after the first (the poll finds no new
# @claude requests, and the agent re-declares).
#
# An empty-text final turn matches nothing, so it still yields ""
# and still trips the existing "nothing to post" warning below.
RESPONSE=$(jq -rs '
def is_decl($m): test($m; "i");
"^[\\s>#*+.)0-9-]*\\*{0,2}Stopping Point\\*{0,2}" as $mark
| flatten(1)
| [ .[]
| select(type == "object" and .type == "assistant")
| (.message.content // [])
| map(select(.type == "text") | .text)
| join("\n\n")
] as $texts
| if ($texts | length) == 0 then ""
elif ($texts[-1] | is_decl($mark) | not) then $texts[-1]
else
( [ range(0; $texts | length)
| select($texts[.] != ""
and (($texts[.] | is_decl($mark)) | not))
] | last ) as $start
| (if $start == null then $texts else $texts[$start:] end)
| [ .[] | select(. != "") ]
| join("\n\n")
end
' < "$FILE")
if [ -z "$RESPONSE" ] || [ "$RESPONSE" = "null" ]; then
echo "::warning::Assistant events present but no text content; nothing to post."
exit 0
fi
# GitHub comment bodies are capped at 65,536 *bytes* in the
# stored value, so truncate by bytes (not characters). 60k
# leaves ~5.5k headroom for the footer.
#
# Both the guard AND the truncation are byte-based:
# - Guard: `wc -c` counts bytes. Bash's `${#var}` counts
# characters under UTF-8 locales (the default on GitHub
# runners), so a response with 30k Chinese characters
# (~90k bytes) would not trip a 60k character guard,
# and the truncation below would never fire — but the
# subsequent `gh issue comment` would 422 on the
# oversized body. Counting bytes here closes that hole.
# - Truncation: `head -c` is strictly byte-based, so the
# cut respects the 65,536-byte cap.
# Caveat: a byte cut may slice the final character mid-
# codepoint, producing a garbled trailing byte sequence
# right before the footer. Acceptable for a corner case
# (responses >60k bytes are rare); the footer still
# renders and the full text is one click away in the
# workflow run log.
MAX_LEN=60000
BYTE_LEN=$(printf '%s' "$RESPONSE" | wc -c)
if [ "$BYTE_LEN" -gt "$MAX_LEN" ]; then
RESPONSE="$(printf '%s' "$RESPONSE" | head -c "$MAX_LEN")
… (response truncated at ${MAX_LEN} bytes; full text in the [workflow run](${RUN_URL}))"
fi
BODY="$(printf '%s\n\n<sub>— posted by @claude post-step from [workflow run](%s)</sub>\n' "$RESPONSE" "$RUN_URL")"
# When the trigger was an inline review comment, reply IN-THREAD to
# it (pulls/.../comments/<id>/replies) so the conversation stays
# anchored to the diff line. Every other trigger gets a top-level
# `gh issue comment` (issue and PR-conversation comments share the
# same endpoint). Fall back to top-level if the reply API fails
# (e.g. the parent review comment was deleted).
#
# The /replies endpoint requires the THREAD-ROOT comment id; a
# reply id returns 422. When the trigger is itself a reply,
# `in_reply_to_id` holds the root id (it is null at the thread
# root), so prefer it and fall back to `comment.id`.
REPLY_TARGET_ID="${{ github.event.comment.in_reply_to_id || github.event.comment.id }}"
if [ "${{ github.event_name }}" = "pull_request_review_comment" ]; then
if jq -n --arg b "$BODY" '{body: $b}' \
| gh api --method POST "repos/${{ github.repository }}/pulls/$ENTITY_NUMBER/comments/$REPLY_TARGET_ID/replies" --input - >/dev/null; then
exit 0
fi
echo "::warning::In-thread reply failed; falling back to a top-level comment."
fi
gh issue comment "$ENTITY_NUMBER" --body "$BODY" \
|| echo "::warning::Could not post Claude's response back to the source thread."
# Dispatch the dedicated reviewer workflow when the triggering
# comment looks like an explicit review request.
#
# Background: `claude-code-review.yml` is the dedicated reviewer
# (sticky-comment + delete-prior-sticky pattern, inline review
# threads, etc.) and only triggers on `pull_request` events
# (opened/synchronize/ready_for_review/reopened) and explicit
# `workflow_dispatch`. `issue_comment` is NOT in its trigger
# list. So when a user types "@claude review" on a PR, the
# event fires *this* workflow (an agent-mode run that emits
# prose via the post-step above) rather than the reviewer.
# See PR #802 comment 4530845824 (2026-05-25): "@claude review"
# produced a 125s agent session with no visible review output
# — nothing was dispatched, nothing was posted.
#
# The earlier `Re-request review and dispatch code review if
# Claude pushed commits` step below dispatches the reviewer
# only when Claude pushes new commits — useful for the
# iterate-on-the-diff loop, but it doesn't cover the case where
# the user just wants a fresh review without code changing
# hands. This step closes that gap by dispatching the reviewer
# whenever the triggering comment body literally contains
# `@claude review`. Both paths can fire on the same run if a
# `@claude review` comment also produces commits; the
# reviewer's own concurrency group cancels the duplicate so
# only the freshest diff is reviewed.
#
# `contains()` is a SUBSTRING match (not word-boundary): bodies
# like `@claude review again` or `@claude reviewer` also match.
# That's intentionally lenient — the cost of a spurious match is
# one extra reviewer run, which the reviewer's concurrency group
# cancels. Do NOT tighten this to `startsWith` or a word-boundary
# check without re-checking the paired skip-predicate on the
# prose-post step above, which must stay in sync.
#
# No `steps.claude.outcome == 'success'` guard here (unlike the
# prose-post step): a review dispatch doesn't depend on the
# agent session's output, so we want it to fire on `@claude
# review` even if the agent step was skipped or cancelled
# (e.g., a concurrency cancellation). The reviewer reviews the
# PR's current diff regardless of what the agent run did.
- name: Dispatch claude-code-review.yml on @claude review comment
if: |
always() &&
(github.event.pull_request.number || github.event.issue.pull_request) &&
(
contains(github.event.comment.body, '@claude review') ||
contains(github.event.review.body, '@claude review')
)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
echo "Dispatching claude-code-review.yml for PR #$PR_NUMBER (@claude review comment)."
gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \
|| echo "::warning::Could not dispatch claude-code-review.yml; review will not auto-run."
- name: Re-request review and dispatch code review if Claude pushed commits
if: always() && (github.event.pull_request.number || github.event.issue.pull_request) && steps.head_before.outputs.sha != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA_BEFORE: ${{ steps.head_before.outputs.sha }}
# Reuse the SHA already fetched by "Capture PR head SHA after
# Claude" rather than calling `gh api pulls/$N` a second time.
SHA_AFTER: ${{ steps.head_after.outputs.sha }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
echo "before=$SHA_BEFORE after=$SHA_AFTER"
# Require a non-empty SHA_AFTER: if "Capture PR head SHA
# after Claude" failed, its output is "" and a bare
# `"" != "$SHA_BEFORE"` would be true, dispatching a
# spurious review against an unchanged diff. Defaulting to
# "no new commits" on an unknown AFTER SHA errs toward not
# dispatching (cheap miss) rather than a wasted review run.
if [ -n "$SHA_AFTER" ] && [ "$SHA_AFTER" != "$SHA_BEFORE" ]; then
echo "Claude pushed new commits; re-requesting review and dispatching code review."
gh api -X POST \
"repos/${{ github.repository }}/pulls/$PR_NUMBER/requested_reviewers" \
-f "reviewers[]=d-morrison" || true
# Fire claude-code-review.yml via workflow_dispatch. GITHUB_TOKEN
# may trigger workflow_dispatch (unlike push, which is blocked to
# avoid recursion) — but ONLY with `actions: write` in this job's
# `permissions:` (see the note there); with `actions: read` the
# dispatch 403s silently. The review workflow's own `concurrency`
# group then cancels any in-flight review for this PR so the
# freshest diff wins.
gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" || \
echo "::warning::Could not dispatch claude-code-review.yml; review will not auto-run."
else
echo "No new commits on PR head; skipping re-request and review dispatch."
fi
# Pair to "Set up branch for issue trigger" above. Pushes the
# branch Claude was working on and opens a draft PR. Runs even
# on Claude failure (`always()`) so that any partial work is
# preserved on a branch rather than discarded with the runner —
# the user can then decide whether to keep it. The `branch !=
# ''` guard means this only fires when the pre-step actually
# created a branch (i.e. for issue triggers).
- name: Push branch and open draft PR for issue trigger
if: |
always() &&
steps.issue_branch.outputs.branch != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.issue_branch.outputs.branch }}
STARTING_SHA: ${{ steps.issue_branch.outputs.starting_sha }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
# Passed via env (not interpolated into the shell body) to
# keep arbitrary issue-title characters out of the shell.
ISSUE_TITLE: ${{ github.event.issue.title }}
# Same reason as ISSUE_TITLE above: passed via env rather than
# interpolated into the shell body, so nothing from the event
# context is expanded by the runner into the script text.
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Sweep changes Claude staged or left untracked but didn't commit, so the HEAD-unchanged check below doesn't drop them.
if [ -n "$(git status --porcelain)" ]; then
git config user.name "claude[bot]"
git config user.email "claude[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: auto-commit residual staged/untracked changes from @claude session" \
|| { echo "::error::auto-commit of residual @claude changes failed"; exit 1; }
fi
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$STARTING_SHA" ]; then
echo "No new commits on $BRANCH — Claude produced no changes; skipping push/PR."
exit 0
fi
echo "Pushing $BRANCH ($STARTING_SHA -> $CURRENT_SHA)"
# Push to an explicit token-bearing URL rather than `origin`.
# The "Checkout submodules" step earlier in this job sets a
# global `url.https://x-access-token:${SUBMODULES_TOKEN}@github.com/.insteadOf
# https://github.com/` rule so submodule clones authenticate
# with SUBMODULES_TOKEN (a fine-grained PAT scoped to the
# submodule repos, not this one). That rule also rewrites
# `git push origin`'s resolved URL — sending the main-repo
# push under SUBMODULES_TOKEN, which has no write access here
# and produces "Authentication failed" (see run #26313620971,
# the first real-world test of this post-step). The URL
# below starts with `https://x-access-token:...@github.com/`,
# which doesn't match the rewrite pattern (`https://github.com/`),
# so it passes through and reaches the remote under
# GITHUB_TOKEN — which has `contents: write` for this repo.
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "$BRANCH"
# Reuse an existing open PR for this branch if one is somehow
# already present (idempotent re-run safety); otherwise open.
EXISTING=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty')
if [ -n "$EXISTING" ]; then
echo "PR already exists for $BRANCH: $EXISTING"
PR_URL="$EXISTING"
PR_VERB="pushed new commits to existing draft PR"
# Note: we deliberately don't dispatch claude-code-review.yml
# in this branch. The only way EXISTING is non-empty is if a
# prior run of this same step already opened the PR (the
# branch name's timestamp makes collisions with a hand-
# opened PR essentially impossible). That prior run would
# have dispatched the review, so re-dispatching here would
# just queue a duplicate review on an unchanged diff.
else
# `printf` (not a HEREDOC) keeps the body free of the
# leading YAML-block indentation that would otherwise show
# up on every line of the rendered PR description.
#
# We deliberately use `Addresses #N` rather than `Closes #N`:
# textbook-repo issues often track ongoing discussions or
# multi-PR feature requests that shouldn't be auto-closed by
# the first @claude PR that merges. The author can manually
# close the issue (or rewrite the PR body to `Closes #N`) if
# a full close is appropriate.
# shellcheck disable=SC2016
# The single quotes are correct and required: this is a printf
# FORMAT string, so the %s placeholders are filled from the
# arguments below, not by the shell. Double-quoting would let the
# shell try to expand the literal backticks around @claude.
PR_BODY=$(printf 'Draft PR opened by `@claude` to address #%s.\n\nTriggered by [workflow run](%s).\n\nAddresses #%s.\n' \
"$ISSUE_NUMBER" "$RUN_URL" "$ISSUE_NUMBER")
# `gh pr create --title ""` returns HTTP 422; GitHub's API
# allows issues to have empty titles on some webhook paths,
# so fall back to a generic title rather than crashing the
# post-step (which would leave the branch pushed but no PR
# opened — confusing state).
if [ -z "$ISSUE_TITLE" ]; then
PR_TITLE="Claude response to issue #${ISSUE_NUMBER}"
else
PR_TITLE="$ISSUE_TITLE"
fi
# Use the repo's actual default branch instead of hard-
# coding "main", so this still works if the repo is ever
# restructured (rename, default-branch swap, fork).
PR_URL=$(gh pr create \
--base "$DEFAULT_BRANCH" \
--head "$BRANCH" \
--draft \
--title "$PR_TITLE" \
--body "$PR_BODY")
echo "Opened draft PR: $PR_URL"
# `gh pr create` returns a URL of the form `.../pull/N`, so
# parameter expansion is cheaper and more reliable than
# round-tripping through `gh pr view`: a transient `gh pr
# view` failure here would leave PR_NUMBER empty, which
# then silently breaks the reviewer-request and dispatch
# calls below (both have `|| echo "::warning::"` fallbacks
# for unrelated reasons, so the empty-PR_NUMBER 404s would
# go unnoticed).
PR_NUMBER="${PR_URL##*/}"
gh api -X POST \
"repos/${{ github.repository }}/pulls/${PR_NUMBER}/requested_reviewers" \
-f "reviewers[]=d-morrison" \
|| echo "::warning::Could not request d-morrison as reviewer."
# Fire claude-code-review.yml the same way the PR-trigger
# post-step above does — see that step's comment for why
# workflow_dispatch (not push) is the right trigger here.
gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \
|| echo "::warning::Could not dispatch claude-code-review.yml."
PR_VERB="opened draft PR"
fi
gh issue comment "$ISSUE_NUMBER" \
--body "Pushed Claude's commits to \`${BRANCH}\` and ${PR_VERB}: ${PR_URL}" \
|| echo "::warning::Could not post PR link as issue comment."