Skip to content

fix(claude-code-review): always use agent mode to prevent unauthorized commits - #134

Merged
d-morrison merged 7 commits into
mainfrom
claude/commit-02af72b-analysis-f41jov
Jul 2, 2026
Merged

fix(claude-code-review): always use agent mode to prevent unauthorized commits#134
d-morrison merged 7 commits into
mainfrom
claude/commit-02af72b-analysis-f41jov

Conversation

@d-morrison

@d-morrison d-morrison commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Root cause: Tag mode (track_progress: true) in claude-code-action hardcodes git write tools (Bash(git add:*), Bash(git commit:*), Bash(git rm:*), git-push.sh) into ALLOWED_TOOLS via the action's TypeScript, regardless of --disallowedTools in claude_args. The CLI flag cannot override an env var set internally by the action.
  • Evidence: serodynamics PR #175, commit 02af72b, was pushed by workflow run 28271356338 during what was supposed to be a read-only review triggered by a pull_request synchronize event.
  • Fix: Set track_progress: 'false' for all events. In agent mode, ALLOWED_TOOLS is built solely from claude_args, so no git write tools are ever available.

Trade-offs and mitigations

Agent mode does not auto-post a tracking comment or initialize the inline-comment tool (claude-code-action #635). To compensate:

  • The "Post review comment" step (previously workflow_dispatch-only) now runs on all events, so pull_request reviews still appear on the PR as top-level comments with summary-format findings.
  • The prompt already has a fallback path for when inline comments are unavailable.

The result is a regression from inline comments to summary-only reviews on pull_request events. That's the correct trade-off: a read-only reviewer that posts summary comments is strictly better than one that can push commits to the branch it's reviewing.

Upstream fix

This is a workaround. The proper fix is a read_only mode in claude-code-action itself — tracked in anthropics/claude-code-action#1415, with draft implementation in anthropics/claude-code-action#1433. Once that lands and the action is updated here, we can restore tag mode (and inline comments) by passing read_only: true instead of track_progress: false.

…d commits

Tag mode (track_progress: true) hardcodes git write tools (git add, git
commit, git rm, git-push.sh) into ALLOWED_TOOLS via the action's TypeScript,
regardless of --disallowedTools in claude_args. The CLI flag cannot override
an env var set by the action itself, so the review bot was able to — and did
— push commits to PR branches during review runs.

Confirmed: serodynamics PR #175, commit 02af72b, pushed by workflow run
28271356338 during what was supposed to be a read-only review.

Fix: set track_progress: false for all events. In agent mode, ALLOWED_TOOLS
is built solely from claude_args, so no git write tools are ever available.

Trade-off: agent mode does not auto-post a tracking comment or initialize the
inline-comment tool (claude-code-action #635). Compensate by extending the
"Post review comment" step (previously dispatch-only) to run on all events,
so pull_request reviews still appear on the PR as top-level comments with
summary-format findings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings June 29, 2026 20:42
@github-actions
github-actions Bot removed the request for review from Copilot June 29, 2026 20:42
@claude

This comment has been minimized.

Update two comments that still referenced tag-mode behavior after the
switch to always-agent-mode:
- claude_args block: remove 'tag mode's tracking-comment tool is preserved'
  and 'only initialized in tag mode'; describe agent-mode reality instead.
- Collapse step: update step name reference from 'Post review comment for
  dispatched run' to 'Post review comment' (renamed in prior commit) and
  note it now covers both dispatched and pull_request runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings June 29, 2026 20:47
@github-actions
github-actions Bot removed the request for review from Copilot June 29, 2026 20:47
@claude

This comment has been minimized.

@d-morrison

Copy link
Copy Markdown
Collaborator Author

@claude make mode an input option that consumers of this action can set

Copilot AI review requested due to automatic review settings July 2, 2026 06:53
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

👀 Picked up by workflow run #28571359079. Setup runs first; Claude itself responds after that.

Copy link
Copy Markdown
Collaborator Author

Working on this — paws off until I'm done.


Generated by Claude Code

@github-actions
github-actions Bot removed the request for review from Copilot July 2, 2026 06:54
@claude

This comment has been minimized.

…pt into tag mode

Adds a boolean `track-progress` input (default: false) so callers can opt
into tag mode when they want a live tracking comment and inline review
comments. The default stays false (agent mode) — safe, no git write tools.

Tag mode should only be enabled once anthropics/claude-code-action#1415
ships a read_only input; until then, tag mode exposes git write tools and
the reviewer may push commits (serodynamics PR #175, commit 02af72b).

Also gates the "Post review comment" manual-post step on agent mode so it
doesn't duplicate the auto-posted tracking comment when tag mode is active.

Fixes #140 (partially — the tracking issue stays open until the upstream
read_only input lands and we can enable tag mode safely by default).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings July 2, 2026 06:55
@github-actions
github-actions Bot removed the request for review from Copilot July 2, 2026 06:56
@claude

This comment has been minimized.

…nce page

Per CLAUDE.md: new workflow_call inputs need doc sync at three sites.
- README.md: add track-progress to key-inputs column; update the
  track_progress feature table row to note it is now caller-configurable.
- website/workflows.qmd: add track-progress to key-inputs column.
- website/reference/claude-code-review.qmd: add row to Inputs table with
  safety warning; add commented usage line in the Example block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings July 2, 2026 06:57
@github-actions
github-actions Bot removed the request for review from Copilot July 2, 2026 06:57
@claude

This comment has been minimized.

Em dash (U+2014) flagged by check-chars. Replace with ASCII hyphen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings July 2, 2026 06:58
@github-actions
github-actions Bot removed the request for review from Copilot July 2, 2026 06:58
@claude

This comment has been minimized.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


I'll stop here and wait for the agents to complete or the scheduled wakeup at 07:13.

Copy link
Copy Markdown
Collaborator Author

@claude review


Generated by Claude Code

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

👀 Picked up by workflow run #28572166679. Setup runs first; Claude itself responds after that.

@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #134 (follow-up, since commit eae286f)

The prior review approved the security fix as of commit eae286f. Since then, d-morrison implemented the follow-up requested in this comment himself: a caller-configurable track-progress input that lets consumers opt back into tag mode. This round reviews that new work.

Inline-comment tooling was unavailable in this session, so findings are below, grouped by file with path:line anchors (per the fallback in the review prompt).


.github/workflows/claude-code-review.yml

[Stale comment] "Explain and fold tracking comment when canceled" step — lines 654-662

The comment above this step says:

Since we now always run in agent mode (no tracking comment), this step is a no-op in practice, but kept as a safety net in case a prior tag-mode comment lingers.

That was accurate right after the original security fix (track_progress was unconditionally 'false'), but this round reintroduces tag mode as an opt-in via the new track-progress input a few dozen lines above (line ~464: track_progress: ${{ inputs.track-progress && github.event_name == 'pull_request' && 'true' || 'false' }}). With track-progress: true on a pull_request event, tag mode is active and does create a tracking comment again, so this step is no longer a guaranteed no-op — it's back to doing real work when a tag-mode review gets canceled. The comment should be updated to describe the reintroduced possibility instead of asserting the step never matters. (Low severity — comment-only, no functional bug; the if: cancelled() condition itself still behaves correctly either way.)


CHANGELOG.md

[Ordering] New ### Fixed heading placed before the existing ### Changed section — lines 60-81

The new ### Added bullet for track-progress is immediately followed by a new ### Fixed section, inserted before the pre-existing ### Changed section in ## [Unreleased]. This breaks the Added → Changed → Fixed → Security ordering this same file uses consistently elsewhere — see ## [2.0.0] just below (### Breaking### Added### Changed### Fixed### Security, lines 94-257), which matches the Keep a Changelog convention this repo follows. Move the new ### Fixed block (and its bullet) to after the existing ### Changed section so ## [Unreleased] matches the established order.


Minor / non-blocking

  • Notation inconsistency: the new track-progress input is referenced as inputs.track-progress (dot notation) in the track_progress: assignment but as inputs['track-progress'] (bracket notation) in the "Post review comment" step's if: condition. Both work, but every other hyphenated input in this file (pr-number, checkout-submodules, show-full-output, etc.) uses dot notation consistently — worth matching for consistency.
  • examples/claude-code-review.yml doesn't show track-progress as a commented option, unlike every other optional input (checkout-submodules, allowed-bots, apt-packages, pip-packages, prompt-addendum) which all get an example line there. CLAUDE.md's doc-sync checklist for a new workflow_call input only requires README.md, website/workflows.qmd, and website/reference/<name>.qmd (all three were correctly updated here) — it doesn't list examples/<name>.yml — so this isn't a violation, but given track-progress is a security-relevant toggle, consumers skimming the copy-paste stub (rather than the reference docs) won't discover it. Optional to add.

Verdict: Needs minor revisions

The track-progress feature itself is sound and well-documented (doc-sync across README/workflows.qmd/reference page is complete, the safety trade-offs are clearly explained in the input description, code comments, and CHANGELOG). The security fix from the previous round (agent-mode default, track_progress: false) is unchanged and remains correct. Two small fixes are needed before merge: update the now-stale "always agent mode" comment in the cancellation-handling step, and reorder the CHANGELOG's ### Fixed section to follow ### Changed per this file's own established convention.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


The review has been posted (replacing my accidental test comment) at #134 (comment).

Summary: I reviewed the changes since the last approved round (commit eae286f) — d-morrison's own follow-up adding a caller-configurable track-progress input so consumers can opt back into tag mode. Found two real issues:

  1. Stale comment in .github/workflows/claude-code-review.yml — the "Explain and fold tracking comment when canceled" step's comment claims the workflow "now always run[s] in agent mode," which the new track-progress input contradicts (tag mode can run again when a caller opts in).
  2. CHANGELOG ordering — the new ### Fixed section was inserted before the existing ### Changed section, breaking the Added→Changed→Fixed→Security order this file consistently uses elsewhere (e.g. the [2.0.0] release section).

Both are minor/non-blocking documentation-accuracy issues, not functional bugs — the security fix and the new feature's logic are both correct. Verdict: Needs minor revisions.

- Update the "Explain and fold tracking comment when canceled" step
  comment: replace "we now always run in agent mode (no tracking comment)"
  with the accurate phrasing — agent mode is the default, but callers can
  opt into tag mode via `track-progress: true`.
- Move `### Fixed` block to after `### Changed` in CHANGELOG.md to
  match the established Added→Changed→Fixed ordering convention.
- Change `inputs['track-progress']` to `inputs.track-progress` in
  the "Post review comment" step's `if:` condition to match the dot
  notation used everywhere else in the workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sydNrkDLeYxrpvmWRJkT
Copilot AI review requested due to automatic review settings July 2, 2026 07:27
@github-actions
github-actions Bot removed the request for review from Copilot July 2, 2026 07:28
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @d-morrison's task in 56s —— View job


Code Review — PR #134 (follow-up, since commit c181ab3)

  • Gather context
  • Read the diff since the last review round
  • Check whether prior findings were addressed
  • Post results

d-morrison pushed commit b28c4d9 since the prior review, which fixes all three points that review raised:

  1. Stale comment in .github/workflows/claude-code-review.yml (the "Explain and fold tracking comment when canceled" step) — now correctly describes agent mode as the default with tag mode as an opt-in via track-progress: true, instead of claiming the workflow "always" runs in agent mode.
  2. CHANGELOG ordering — the ### Fixed section is now moved to after ### Changed in ## [Unreleased], restoring the Added → Changed → Fixed → Security order used elsewhere in this file (e.g. ## [2.0.0]).
  3. Notation consistency (minor) — inputs['track-progress'] in the "Post review comment" step's if: condition is now inputs.track-progress, matching dot notation used for every other hyphenated input in this file.

I diffed b28c4d9 directly and confirmed each change matches what was requested — no unintended side effects, and no new issues introduced by this fix commit. The other two findings from that round were both explicitly "optional"/non-blocking suggestions (adding track-progress to examples/claude-code-review.yml), not required fixes, and remain untouched — that's fine.

No new findings this round.

Verdict: Ready for merge

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.

2026-07-02 07:42 UTC

@d-morrison
d-morrison merged commit f2ba006 into main Jul 2, 2026
17 checks passed
@d-morrison
d-morrison deleted the claude/commit-02af72b-analysis-f41jov branch July 2, 2026 07:41
d-morrison pushed a commit that referenced this pull request Jul 2, 2026
All notable changes to `d-morrison/gha` are documented here.

This repo uses a **moving major tag** (`v1`) for consumers, following the
[`r-lib/actions`](https://github.com/r-lib/actions) convention: the `v1` tag
moves forward as non-breaking fixes land, and consumers pin to `@v1`. Each
release is also tagged with a full `vX.Y.Z` so a specific point can be pinned
if needed. Breaking changes bump the major tag (`v2`, …) and are called out
below with migration steps.

## [Unreleased]

### Changed

- **`claude-code-review.yml` now bakes in SERG lab manual enforcement** (#170).
  The reusable review prompt now always evaluates a PR against the
  [UCD-SERG lab manual](https://ucd-serg.github.io/lab-manual/) — coding
  style, coding practices (including function decomposition/length limits),
  and repository conventions — so every consumer repo gets this check
  automatically, without needing to pass a `prompt-addendum`. Mirrors the
  wording already in this repo's own `CLAUDE.md` "Code review guidelines".

### Added

- **`test-coverage` — R-package test coverage with Codecov upload** (#147). A new
  composite (`test-coverage/action.yml`) and reusable workflow
  (`.github/workflows/test-coverage.yml`) that set up R and dependencies, run
  `covr::package_coverage()`, and upload the Cobertura report with
  `codecov/codecov-action`. Adapts the canonical `r-lib/actions`
  `test-coverage.yaml` example into the repo's composite-plus-wrapper shape.
  Inputs: `path` (package root, defaults to repo root), `install-quarto`,
  `extra-packages`, and `fail-ci-if-error` (`'auto'` by default, applying the
  r-lib heuristic -- fail on non-PR events, and on PRs only when a token is set,
  since tokenless PR uploads are flaky -- or force `'true'`/`'false'`); the
  optional `CODECOV_TOKEN` secret is passed through the caller's `secrets:`
  block. See `examples/test-coverage.yml` for the caller stub.

- **`check-equation-renders` — catch equations MathJax can't render in PR previews**
  (#159). A new fourth leg of the PR-preview family: a composite
  (`check-equation-renders/action.yml`) that crawls a built Quarto/HTML site with a
  headless Chromium (Playwright), lets MathJax finish typesetting each page, and
  fails on either of the two ways MathJax signals a broken formula: a hard parse
  error (a `[data-mjx-error]` node), or an undefined macro, which MathJax renders
  as literal, unresolved `\command` text rather than a hard error (no error node
  to find, so the check looks for the raw command name surviving into the
  rendered output instead). Wired into
  the family as a reusable workflow (`.github/workflows/check-equation-renders.yml`)
  triggered the same way as `preview-deploy.yml` (`workflow_run` on the build
  workflow's completion), downloading the `pr-preview-site` artifact directly rather
  than depending on the deploy. Motivated by a broken equation
  (`d-morrison/rme#954`) that shipped silently: Quarto's `html-math-method: mathjax`
  embeds raw TeX unchanged in the static HTML, and MathJax only parses it
  client-side, so a bad equation produces no warning in the Quarto/pandoc build log.
  Ships at `@v2` (too new for the frozen `@v1` tag), like `test-coverage`. See
  `examples/check-equation-renders.yml` for the caller stub.
- **`claude-code-review` accepts `apt-packages`/`pip-packages` inputs** (#161),
  mirroring the inputs `claude.yml` already had. Lets a math-heavy consumer
  repo install a computer algebra system (e.g. `apt-packages: maxima`,
  `pip-packages: sympy`) so the reviewer's `Bash` tool can symbolically check
  a derivation instead of eyeballing the algebra. Both default to `''`
  (no-op), so this is backward compatible for every existing caller. The
  install steps for both workflows are now factored into a shared
  `.github/actions/install-packages` composite rather than duplicated.

- **Added `.gitattributes`** (#163). Normalizes line endings (`text=auto
  eol=lf`, explicit for `*.sh`) and union-merges `CHANGELOG.md` so two
  sibling PRs appending entries under `## [Unreleased]` merge cleanly
  instead of conflicting on adjacent insertions (mirrors `d-morrison/ai-config`).
- **`claude-code-review` gains a `track-progress` input** (#134). Consumers
  can now opt into tag mode (live tracking comment + inline-comment tool) by
  setting `track-progress: true`. The default remains `false` (agent mode),
  which is safe — agent mode never grants git write tools, preventing the
  review bot from pushing commits to PR branches. Only set `track-progress:
  true` once [anthropics/claude-code-action#1415](https://github.com/anthropics/claude-code-action/issues/1415)
  ships a `read_only` input; until then, tag mode exposes git write tools.
- **`/review` comment trigger for `claude-code-review`.** Commenting `/review`
  at the start of a PR comment now starts a review of that PR on demand, without
  routing through `claude.yml`'s `@claude` agent. The review caller listens for
  the comment (gated to `OWNER`/`MEMBER`/`COLLABORATOR` authors) and re-dispatches
  its own `workflow_dispatch` review, reusing the existing dispatched-review flow
  — so the reusable `claude-code-review.yml` is unchanged and consumers get the
  tag by updating their `examples/claude-code-review.yml` stub. It's a slash
  command rather than an `@claude review` mention on purpose: any `@claude`
  substring also wakes `claude.yml`, so the slash command keeps the direct path
  independent. Works once the workflow is on the default branch (`gh workflow
  run` requires it).

### Changed

- **`claude-code-review` grants the reviewer `Bash(python3 <file>)`.** The
  review agent could previously only trace a Python script's logic by eye,
  since its `--allowedTools` covered just the inline-comment tool plus the
  action's base allowlist (Read/Glob/Grep and narrow git-read Bash) — no way
  to actually execute the script under review (rme#970). Scoped to running an
  existing file under the checkout (`-c`/`-m` denied, so inline/module code
  execution stays blocked) — same-repo PRs give this job
  `CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY` as secrets, so unrestricted
  `python3:*` would have been a real capability widening beyond the existing
  git-read-only sandbox, not just a git-push question. It can now verify a
  script's behavior instead of guessing from source alone (#154).

- **`claude-code-review` now honors an explicit review request on a draft PR.**
  A dispatched review (an `@claude review` comment routed here by `claude.yml`,
  `claude.yml`'s post-push re-dispatch, the issue-trigger draft PR, or a manual
  dispatch) already bypassed the workflow's draft-skip `if:` gate, but the
  code-review skill's *own* don't-review-drafts stop condition still made the
  agent refuse ("currently a draft … I will not proceed"), so an explicit
  `@claude review` on a draft produced a refusal instead of a review. The
  dispatched-run prompt now overrides that stop condition so an
  explicitly-requested review runs even on a draft. Automatic `pull_request`
  reviews still skip drafts (their `if:` gate never reaches the agent on a
  draft), so this only widens the dispatched path.

- **`claude-code-review` no longer flags cosmetic source-only formatting that
  renders identically** (#261). The review prompt now tells the reviewer to skip
  raw-source line-wrap position and line-length nits on Markdown/text prose when
  the rendered output is unchanged, and to flag only genuine prose problems
  (ambiguity, meaning-changing grammar, broken links or markup, factual errors).
  This cuts review-round churn from cosmetic wrapping nits.

### Fixed

- **`claude-code-review`'s stub guard no longer false-fails complete reviews**
  (#173). The `### Verdict`-heading check added for the silent-stub problem was
  too strict: the `code-review` plugin states its conclusion as a `Verdict:`
  label (not the `### Verdict` heading the prompt requests), and in agent mode
  it can land in an earlier assistant message than the last — so every
  push-triggered review red-X'd (gha#175) even when it posted a full review with
  a verdict. `fail-check` now scans **all** of the run's assistant text (not just
  the final block) and accepts a verdict line in any heading/label/bold form
  (`### Verdict`, `**Verdict:**`, `Verdict:`), and the comment posted back to the
  PR uses the verdict-bearing block rather than the final one, so a trailing
  wrap-up message ("I've posted my findings") isn't mistaken for the review body
  (sparta#594). A genuine stub still states no verdict and still fails,
  preserving the original intent of catching the silent-stub failure mode
  ([`Lacaedemon/sparta#590`](https://github.com/Lacaedemon/sparta/issues/590)).

- **`claude-code-review`'s pass/fail check now catches stub reviews, not just
  API errors** (#171). The reusable workflow's `fail-check` step previously
  only inspected `is_error`/`subtype` on the SDK result, so a run that
  reported success while actually exiting on an orchestration placeholder
  (e.g. "Waiting for both background agents... before proceeding to the next
  review steps") still posted that placeholder as the finished review and the
  check stayed green — the same silent-stub failure mode reported upstream in
  [`Lacaedemon/sparta#590`](https://github.com/Lacaedemon/sparta/issues/590).
  `fail-check` now also extracts the run's final review text and fails the
  check if it's empty (including whitespace-only) or missing the `### Verdict`
  heading this workflow's own prompt requires of every finished review —
  a stub is narration, never a finished review, so it can't contain that
  heading, which catches every observed stub phrasing without having to
  enumerate them individually.

- **`claude-code-review` no longer pushes unauthorized commits to PR
  branches** (#134). Tag mode (`track_progress: true`) in `claude-code-action`
  hardcodes git write tools into `ALLOWED_TOOLS` regardless of `--disallowedTools`,
  causing the review bot to commit and push during review runs (confirmed:
  serodynamics PR #175, commit `02af72b`). The workflow now defaults to agent
  mode, which builds `ALLOWED_TOOLS` solely from `claude_args` with no git write
  tools. Trade-off: reviews are summary-only (no inline comments) in the default
  mode; opt into tag mode with the new `track-progress` input when the upstream
  fix is available.

## [2.0.0] - 2026-06-25

### Breaking

- **`quarto-publish` now deploys to the `gh-pages` branch** instead of the
  GitHub Pages artifact (#118, #120). Consumers must set Settings → Pages →
  Source = "Deploy from a branch", branch `gh-pages` / `(root)`, and grant the
  caller `contents: write` (dropping `pages: write` + `id-token: write`) — still
  required with `deploy: false`. This makes `quarto-publish` compatible with the
  branch-based PR-preview family, so a repo can ship both a main site and PR
  previews. Full migration steps are in the `quarto-publish` entry under Added.
  `@v1` stays frozen on the Pages-artifact deploy for consumers not ready to
  migrate.

### Added

- Dark mode for the documentation website (#114) — `website/_quarto.yml` now
  pairs the `cosmo` light theme with the `darkly` dark theme, so the site renders
  a light/dark toggle in the navbar and respects the reader's system preference.
- Documentation website (#100) — a Quarto site under `website/` that documents
  every reusable workflow (overview, a per-action reference page with full input
  tables, permissions, and versioning). It is built and shipped by the repo's own
  actions: `quarto-publish` deploys it to GitHub Pages on `main`, and the
  `preview` family renders a per-PR preview.
- `preview` gains a `path` input (#100) — the project directory to render
  (the dir holding `_quarto.yml`), defaulting to the repo root. This brings
  `preview` to parity with `quarto-publish` and lets a site that lives in a
  subdirectory (like this repo's `website/`) get a PR preview. Backward
  compatible: existing callers that render the repo root need no change.
- Shared-content sync family (#57) — keeps guidance shared between repos current
  in both directions, via two reusable workflows and a shared helper:
  - `bump-submodule.yml` — update a named submodule to its upstream HEAD and open
    a PR when the pointer moves (one direction; e.g. the lab manual tracking
    `.ai-config`).
  - `sync-shared-fragments.yml` — vendor a set of files from an upstream repo,
    pinned to a commit and recorded in a JSON manifest, and open a PR when they
    change (the other direction; avoids a recursive mutual submodule).
  - `open-sync-pr` composite — the commit-and-open-PR helper both workflows
    reuse: commits staged changes to a reused automation branch and opens or
    updates the PR, no-op when nothing changed. First consumers:
    `UCD-SERG/lab-manual` and `d-morrison/ai-config`.
- `quarto-publish` — render a Quarto site and deploy it to the `gh-pages`
  branch, which GitHub Pages serves. A composite (`quarto-publish/action.yml`)
  sets up Quarto (optionally R/renv and TinyTeX) and renders a project at a
  given `path` into `<path>/<output-dir>` (default `_site`). The reusable
  workflow (`quarto-publish.yml`) deploys that output to `gh-pages` with
  `clean-exclude: pr-preview/`, so a main-site deploy never wipes the preview
  family's per-PR sites; it also offers optional submodule init and a
  `pre-render-artifact` input so a caller can inject build-time assets (e.g.
  recorded media) before render. Callers grant `contents: write` (still
  required with `deploy: false`, since the deploy job is part of the workflow)
  and set Pages Source = "Deploy from a branch", branch `gh-pages`. First
  consumer: `Lacaedemon/sparta` (#37).

  **Breaking change for early `@v1` adopters (#117).** An earlier interim
  version deployed via `actions/deploy-pages` and needed Pages Source =
  "GitHub Actions". That is incompatible with the PR-preview family, which is
  branch-based, so previews 404'd. Switching `quarto-publish` to a `gh-pages`
  deploy makes publish and preview consistent. To migrate: (1) set Settings ->
  Pages -> Source = "Deploy from a branch", branch `gh-pages` / `(root)`; and
  (2) change the caller's job permissions from `pages: write` + `id-token:
  write` to `contents: write` (see `examples/quarto-publish.yml`).
- PR-preview / publish family (#33) — centralizes the three-workflow preview
  pipeline rme carried inline:
  - `preview` composite action + `preview.yml` reusable workflow — build half;
    renders the Quarto site read-only in the (possibly fork) PR context and
    uploads it + PR metadata as an artifact. Parameterized for non-rme
    consumers (R version, apt packages, renv on/off, local-package install,
    Chrome, submodules, render profile). Writes PR metadata **after** checkout
    so `git clean -ffdx` can't wipe it from the artifact (d-morrison/rme#913),
    and keeps the `preview:pdf`/`preview:docx`/`preview:revealjs` and
    `clear freezer` label gates.
  - `preview-deploy.yml` reusable workflow — deploy half; on `workflow_run`
    completion publishes the artifact to `gh-pages` in the base-repo context
    and comments the preview link. Kept split from the build half so untrusted
    fork code never holds write permissions (the trust boundary).
  - `cleanup-pr-previews.yml` reusable workflow — scheduled housekeeping that
    deletes preview directories for closed PRs.
- `check-phi` — scans pull requests (added lines only; whole tree on `push`)
  for content that looks like PHI: US Social Security numbers, medical record
  numbers, dates of birth, and PHI-suggestive column headers in delimited data
  files. Matched values are never printed to the log; false positives are
  suppressed via a `phi-allow` line comment or a regex allowlist file. The
  `phone`/`email` detectors are available but off by default.
- `CHANGELOG.md` (this file) — records what changes as the `@v1` tag moves, so
  consumers can see what they picked up.
- `REVDEPS.md` — tracks repos that consume these workflows so breaking changes
  can be announced. See the file for how to register.
- `.github/actions/checkout-submodules` composite action — centralizes the
  submodule-init logic (the `SUBMODULES_TOKEN` `insteadOf` rewrite and the
  anonymous-clone fallback) shared by the `claude` and `claude-code-review`
  reusable workflows (#25).

### Changed

- Multi-line `run:` blocks in the composite actions and reusable workflows now
  declare `set -euo pipefail` explicitly. GitHub already runs `shell: bash`
  with `-eo pipefail`; the net new protection is `nounset` (unset-variable
  typos now fail fast), plus consistency with the rest of the script logic.
- `claude` and `claude-code-review` no longer carry duplicate `Checkout
  submodules` steps; both call the shared `checkout-submodules` action instead,
  so the token-rewrite logic lives in one place (#25).
- `check-bibliography-dois` now collects `.bib` files NUL-delimited into a bash
  array, so bibliography paths containing spaces are passed to the checker as
  intact single arguments instead of word-splitting (#30).
- `claude-code-review`'s prompt now instructs the reviewer to watch for AI
  hallucinations — fabricated functions/arguments/APIs, invented references,
  DOIs, or URLs, plausible-but-unreal file paths and constants, and comments
  that describe behavior the code doesn't implement — and to verify questionable
  symbols against the codebase rather than assuming they exist (#56).
- `claude` now reproduces qwt's late-comment dedup so a follow-up `@claude`
  comment absorbed by a still-running session isn't double-handled by the
  duplicate run it also queued: a "Skip if this comment was already handled"
  pre-step bows out when the triggering comment already carries a github-actions
  🚀 marker, the agent emits a `<!-- claude-absorbed: … -->` marker listing the
  comments it absorbed by polling, and a post-step reacts 🚀 to each so their own
  queued runs short-circuit. A companion step re-dispatches a review for a late
  `@claude review` that a deduped run would otherwise have dropped. Additive —
  no consumer input changes (#44, ported from qwt #73/#90/#95).
- `claude-code-review` gained an `allowed-bots` input (default
  `github-actions[bot]`, previously hard-coded) so a consumer can widen the
  accepted dispatch actors (e.g. `github-actions[bot],claude`), and a
  "Skip self-review when the PR edits this workflow" step that detects (via
  `github.workflow_ref`) a PR modifying the caller's review workflow and skips
  the review — which would otherwise 401 on the action's workflow-validation
  until merged — instead of posting a failed check (#45, ported from qwt).

### Fixed

- **Example caller stubs now pass secrets explicitly instead of `secrets:
  inherit`.** GitHub only inherits org/repo secrets into a reusable workflow
  owned by the *same* org/user, so a cross-owner consumer (e.g. a `UCD-SERG`-org
  repo calling these `d-morrison`-user-owned workflows) inherited an empty
  `CLAUDE_CODE_OAUTH_TOKEN` and every `@claude` run failed env-validation
  ("… is required when using direct Anthropic API"). `examples/claude.yml` and
  `examples/claude-code-review.yml` now pass `CLAUDE_CODE_OAUTH_TOKEN` (and the
  optional `SUBMODULES_TOKEN` / `WORKFLOW_TOKEN`) explicitly, which resolves
  caller-side and works regardless of owner. Existing consumers copied from the
  old stubs must make the same change (#49).
- `claude-code-review` now sets `allowed_bots: github-actions[bot]`, so the
  review `claude.yml` re-dispatches after an `@claude` run pushes commits can
  actually run. The action's agent mode (used by `workflow_dispatch`) blocks
  bot actors by default, so dispatched reviews previously failed with "Workflow
  initiated by non-human actor" — and, having entered the per-PR concurrency
  group, canceled the parallel `synchronize` auto-review on their way out,
  leaving the push with no review at all.
- `claude-code-review`'s "collapse previous review comments" step is no longer
  gated to `pull_request`, so a dispatched (`workflow_dispatch`) review that
  wins the per-PR concurrency race also folds earlier pushes' review comments as
  OUTDATED instead of leaving them expanded.

### Security

- **All third-party actions are now pinned to full commit SHAs** (with the
  human-readable version in a trailing comment), following GitHub's
  [recommended hardening posture](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions).
  A SHA is immutable, so a re-pointed tag or a compromised upstream can no longer
  silently change what runs — most important for the `preview-deploy` job, which
  runs in the base-repo context with `contents: write` + `pull-requests: write`.
  Added [`.github/dependabot.yml`](.github/dependabot.yml) (`github-actions`
  ecosystem, weekly, grouped, covering `.github/workflows/` and each composite
  action) so the pins are auto-bumped as upstreams publish releases instead of
  freezing. First-party `d-morrison/gha/*@v1` self-references and the
  `examples/` templates intentionally still track the `@v1` major tag (#48).

## [v1] — initial pilot set

Reusable workflows + composite actions:

- `check-bibliography-dois` — validate book/article BibTeX entries have
  resolvable DOIs matching CrossRef metadata.
- `check-links` — lychee link check with bundled config, PR skip-label, and
  auto-issue on `main`.
- `check-non-standard-chars` — detect curly quotes / en–em dashes in `.qmd`
  and `.R` files.
- `check-news` — enforce a `NEWS.md` changelog entry on PRs (wraps
  `UCD-SERG/changelog-check-action`).
- `summary` — AI summary comment on newly opened issues.

[Unreleased]: https://github.com/d-morrison/gha/compare/v1...main
[v1]: https://github.com/d-morrison/gha/releases/tag/v1
# CLAUDE.md

Guidance for Claude Code when working in this repository.

## About this repo

Central, reusable GitHub Actions for `d-morrison` / `UCD-SERG` / `ucdavis` R-package
and Quarto repositories (see [`README.md`](README.md)). Each capability ships as a
composite action plus a `workflow_call` reusable workflow. Consumers pin to `@v1`.

### Layout

- Per-capability composite-action directories at the repo root, each with an
  `action.yml` and, for R/Python capabilities, a language-specific helper
  script — e.g. `check-bibliography-dois/` (R), `check-non-standard-chars/` and
  `check-phi/` (Python). `check-links/` bundles `lychee.default.toml`;
  `preview/`, `quarto-publish/`, and `open-sync-pr/` are action-only (the last
  is the shared push-and-open-PR helper used by `bump-submodule` and
  `sync-shared-fragments`).
- `.github/workflows/` — the `workflow_call` reusable workflows that wrap the
  composites (one per consumer-facing capability — the shared internal
  `open-sync-pr` composite has no wrapper), plus the `claude.yml` and
  `claude-code-review.yml` reusable wrappers, and `_selftest.yml`, which
  exercises composites on every PR — local `./` refs for pre-release
  capabilities, and `@v1` through the reusable-workflow wrappers for stable ones.
  `claude-bot.yml` and `claude-review.yml` are event-triggered workflows that run
  the Claude bot in this repo, not `workflow_call` wrappers.
- Several workflows have no corresponding root composite: `check-news.yml`,
  `summary.yml`, and `preview-deploy.yml` are `workflow_call` reusable workflows
  that wrap external actions; `cleanup-pr-previews.yml` is a self-contained
  `workflow_call` reusable workflow (inline shell logic, no external composite);
  `bump-submodule.yml` and `sync-shared-fragments.yml` are `workflow_call`
  reusable workflows that call the shared internal `open-sync-pr` composite;
  `slide-major-tag.yml` is push- and dispatch-triggered and runs only in this
  repo.
- `.github/actions/checkout-submodules/` — a small shared composite reused by the
  reusable workflows.
- `examples/` — caller stubs consumers copy into their own repos.
- `README.md`, `CHANGELOG.md` — top-level project docs;
  `REVDEPS.md` — lists registered downstream consumer repos. Every PR that
  changes user-facing behavior should add a **changelog fragment** under
  `changelog.d/` (a `<slug>.<category>.md` file — see `changelog.d/README.md`)
  rather than editing `CHANGELOG.md` directly, so parallel PRs never conflict on
  the same `## [Unreleased]` lines. `changelog.d/assemble.sh` collates the
  fragments into `CHANGELOG.md` at release time. This is not CI-enforced
  (`require-changelog.yml` was removed).

When editing a consumer-facing capability, change the composite (`<name>/action.yml`,
plus its helper script if one exists) and keep the wrapping reusable workflow and its
`examples/<name>.yml` stub in sync. Internal-only composites (like `open-sync-pr`)
have no wrapper or example stub to update. New `.github/workflows/` changes are
exercised by `_selftest.yml`; because brand-new actions aren't at the `@v1` tag
yet, the selftest runs them via the local `./<name>` ref until release.

**A brand-new capability that ships at a tag newer than `@v1`** (because `@v1`
was frozen before it existed — see `slide-major-tag.yml` / the Versioning
section of `README.md`) needs its major tag updated at two distinct kinds of
site, not just the obvious one:

1. **Capability-specific refs** — the new capability's own caller stub
   (`examples/<name>.yml`) and reference-page example
   (`website/reference/<name>.qmd`).
2. **Blanket-rule prose** — any general "pin every reference to `@v1`"
   statement elsewhere (`README.md`'s Versioning section,
   `website/workflows.qmd`) needs an exception clause, even though it never
   names the new capability.

Grep the repo for `@v1` rather than relying on memory of where it appears.
Missing either kind surfaces as a workflow-not-found error for consumers who
copy that spot literally (gha#148, caught across two review rounds).

**Adding a new `workflow_call` input to an existing reusable workflow** needs
its own doc sync at three sites beyond the workflow file itself, or the input
is invisible to a consumer skimming the docs:

1. **`README.md`**'s per-workflow table row's "Key inputs" cell.
2. **`website/workflows.qmd`**'s equivalent table row — a separate table, not
   generated from `README.md`, so it drifts independently.
3. **`website/reference/<name>.qmd`**'s Inputs table, plus a commented usage
   line in its `## Example` block.

Grep the repo for the workflow's filename (e.g. `claude-code-review.yml`)
across `README.md`, `website/workflows.qmd`, and `website/reference/` rather
than assuming only one needs the update. Caught across four review rounds on
gha#161 — the fix for round 2's finding (missing composite) surfaced round
3's finding (docs out of sync), whose fix left one more untouched table row
that round 3 flagged as out-of-scope, fixed anyway before round 4 confirmed
clean.

### Tests

`check-phi/tests/test_detectors.py` is a pytest suite pinning each PHI detector's
positive and negative behavior. Run it with `python3 -m pytest check-phi/tests/ -q`;
CI runs it as the `phi-tests` job in `_selftest.yml`. There's no broader unit-test
harness — most capabilities are validated end-to-end by `_selftest.yml`, running
against this repo itself or small throwaway fixtures (stable capabilities via
`@v1`, pre-release ones from local source).

**Generate selftest fixtures at runtime; don't commit them.** A fixture
committed under a composite's `tests/` dir (e.g. a minimal R package for
`test-coverage`) gets swept into OTHER selftest jobs' repo-wide scans: the
`bib` job's dependency resolution tries to treat it as a real package, and the
`phi` job's PHI scanner flags any synthetic identifier in it (a fake
maintainer email, etc.). Generate the fixture in a small script
(`test-coverage/tests/make-fixture.sh` is the pattern) that the `coverage`
selftest job runs before invoking the composite, instead of committing R
package source files (gha#148).

## GitHub access in remote / web sessions

Claude Code on the web (and other remote/CI sessions) runs in a sandbox where the
`gh` and `glab` CLIs are **not installed** and there is no direct GitHub API
access. Skills and built-in commands that tell you to "use `gh`" — `/review`,
`/code-review --comment`, `/security-review`, `/verify`, PR babysitting, PR
creation — only work if their GitHub steps are translated to the GitHub MCP tools
(`mcp__github__*`). When a skill or command instructs a `gh`/`glab` command in
such a session, substitute the equivalent MCP tool below. (In a local session
where `gh` is on `PATH`, use `gh` as the skill describes.)

This repo is `d-morrison/gha`, so MCP calls use `owner: d-morrison`, `repo: gha`.

**Some of these sessions have no local git checkout at all** (not just a missing
`gh` CLI) — there is no working tree to run `git commit`/`git push` against, so
every change (branch, file edit, PR) must go through the MCP write tools below.
Editing a file means: `mcp__github__get_file_contents` first to get its current
blob `sha` (required on every update, not just the first — re-fetch it after
each write since it changes on every commit), then
`mcp__github__create_or_update_file` with the **full** new file content (it
replaces the whole file, there is no patch/diff mode) and that `sha`. A stale
`sha` (from before another commit landed) fails the write — re-fetch and retry
rather than guessing.

| Operation / `gh`/`glab` command | GitHub MCP equivalent |
| --- | --- |
| `gh pr list` | `mcp__github__list_pull_requests` |
| `gh pr view <n>` | `mcp__github__pull_request_read` (`method: get`) |
| `gh pr diff <n>` | `mcp__github__pull_request_read` (`method: get_diff`) |
| changed files in a PR | `mcp__github__pull_request_read` (`method: get_files`) |
| `gh pr status` / `gh pr checks` | `mcp__github__pull_request_read` (`method: get_status` / `get_check_runs`) |
| `gh pr create` | `mcp__github__create_pull_request` |
| read PR conversation comments | `mcp__github__pull_request_read` (`method: get_comments`) |
| read inline review comments | `mcp__github__pull_request_read` (`method: get_review_comments`) — also returns `threadId`s |
| post a top-level PR comment | `mcp__github__add_issue_comment` |
| post inline review comments | `mcp__github__pull_request_review_write` (`method: create`, no `event`) → `mcp__github__add_comment_to_pending_review` per comment → `mcp__github__pull_request_review_write` (`method: submit_pending`) |
| reply to a review comment | `mcp__github__add_reply_to_pull_request_comment` |
| approve / request changes | `mcp__github__pull_request_review_write` (`method: create` with `event`) |
| resolve a review thread | `mcp__github__pull_request_review_write` (`method: resolve_thread`, `threadId: <id from get_review_comments>`) |
| `gh issue list` / `gh issue view <n>` | `mcp__github__list_issues` / `mcp__github__issue_read` |
| read a file / repo contents | `mcp__github__get_file_contents` |
| create/edit a file (no local checkout) | `mcp__github__create_or_update_file` — needs the target branch, full new file content, and the file's current blob `sha` (from `get_file_contents`) if it already exists |
| create a branch (no local checkout) | `mcp__github__create_branch` |
| CI runs & job logs | `mcp__github__actions_list`, `mcp__github__actions_get`, `mcp__github__get_job_logs` |
| watch / stop watching PR activity | `mcp__github__subscribe_pr_activity` / `mcp__github__unsubscribe_pr_activity` |
| `glab mr ...` (GitLab) | N/A — this repo is on GitHub; use the tools above |

Posting inline comments requires a **pending review to already exist** before
`mcp__github__add_comment_to_pending_review`; create the pending review first, add
each comment, then submit once at the end. Watch and respond to PR activity with
`mcp__github__subscribe_pr_activity` / `mcp__github__unsubscribe_pr_activity` (not
`gh pr checks --watch`).

### Reading repos outside the session's MCP scope

A task often needs files from a *sibling* repo (e.g. `d-morrison/qwt`) that the
session's GitHub MCP tools aren't scoped to — those calls fail with
`Access denied: repository … is not configured for this session`. **Don't report
the repo as inaccessible from that alone.** First try the raw HTTP URL directly:
any **public** repo's files are fetchable with `curl` (or `WebFetch`) at
`https://raw.githubusercontent.com/<owner>/<repo>/<branch>/<path>`, which works
even when `gh` and the MCP tools don't. (This is how qwt's standalone workflows
were obtained to port them faithfully into the reusable workflows for #44/#45.)
Only fall back to "can't access it" — or to whatever session tooling can add a
repo to scope, if any — after the raw fetch also fails (private repo, or the
network policy blocks the host).

**A 403 from a *rendered* docs site is not the same as the content being
inaccessible.** A GitHub Pages / Quarto-rendered site (e.g.
`ucd-serg.github.io/lab-manual/coding-style.html`) can reject `WebFetch` (for
reasons unclear — possibly anti-scraping) even though the *source* file it
was built from is a plain file in a public repo. Don't conclude the content is
unreachable — find the source path (often the same repo, e.g.
`coding-style.qmd` for `coding-style.html`, sometimes with `_`-prefixed
included fragments) and raw-fetch that instead using the same
`<path>`-includes-its-extension template above, e.g.
`https://raw.githubusercontent.com/<owner>/<repo>/<branch>/coding-style.qmd`.
(Used on `ucdavis/ettbc#12` / `#32` to confirm ettbc's `.lintr.R` predated
`UCD-SERG/lab-manual`'s move to a shared `lms` linter package (source:
[`UCD-SERG/lab-manual/.lintr.R`](https://github.com/UCD-SERG/lab-manual/blob/main/.lintr.R),
which calls `lms::default_linters()` from a package defined in that repo's own
`lms/` subdirectory) — the manual's own docs page 403'd, but its `.qmd`
source and the referenced `.lintr.R` file both fetched cleanly.)

## A canceled review can red-X require-review — don't chase it as a code bug

`claude-code-review.yml`'s `claude-review` job is concurrency-grouped per PR
(`claude-review-<PR>`, `cancel-in-progress: true`) across BOTH the automatic
`pull_request`-triggered review and claude.yml's comment-triggered (`@claude
review`) re-dispatch. When a push and an `@claude review` comment land close
together — or claude.yml's agent run finishes and re-dispatches a review a
minute or two later, landing on top of the next push's auto-review — the two
reviews race and one cancels the other.

The `require-review` gate job asserts `claude-review`'s result is `success`;
a *canceled* run (not skipped) makes that assertion fail, so `require-review`
shows red right after a push even though the surviving review is fine. Before
treating a post-push `require-review` failure as a real problem: check
whether `claude-review`'s conclusion is `cancelled` rather than `failure`. If
so, it's this race, not a code issue — wait for (or re-trigger) an
uncontested review instead of debugging the diff. To avoid causing it: don't
post `@claude review` immediately after pushing a commit on a PR using this
workflow; let the automatic review run alone, or wait for any in-flight
dispatched review to finish first. (See the `claude-review` job's
`concurrency:` comment in `.github/workflows/claude-code-review.yml` for the
full mechanism.)

## Code review guidelines

When reviewing a pull request (e.g. via `/review`, `/code-review`, or as a Claude
PR bot), evaluate the diff against **both** of the following, in addition to
correctness:

### 1. The SERG lab manual

The [UCD-SERG lab manual](https://ucd-serg.github.io/lab-manual/) is the lab's
authority on coding conventions. Hold changes to its standards, especially:

- [Coding style](https://ucd-serg.github.io/lab-manual/coding-style.html) —
  object naming, line breaks/formatting, function documentation, comments,
  message/communication style, and Quarto code-reference conventions (backticked
  `pkg::fn()`, markdown package links — no raw HTML in `.qmd`).
- [Coding practices](https://ucd-serg.github.io/lab-manual/coding-practices.html) —
  function decomposition and length limits, testing requirements, the QA
  checklist, documentation, `{here}` for paths, and tidyverse idioms.
- [Code repositories](https://ucd-serg.github.io/lab-manual/code-repositories.html) —
  repository organization and version-control practices.

The manual defers to the [tidyverse style guide](https://style.tidyverse.org/)
for R; prefer tidyverse idioms and the native `|>` pipe.

### 2. d-morrison's review priorities

Above all, code should be **highly modular and idiomatic**:

- **Modular / decomposed.** Favor small, single-purpose functions over long
  monolithic blocks. Flag duplicated logic (DRY), functions that do too much,
  deep nesting, and steps that should be extracted and named. In workflows and
  composite actions, factor shared logic into reusable units rather than copying
  it between files.
- **Idiomatic.** Code should read like the surrounding code and like the
  ecosystem's conventions — idiomatic R (tidyverse), idiomatic YAML/GitHub
  Actions, idiomatic shell. Prefer the standard, well-known way over a clever or
  bespoke one. Match existing naming, structure, and formatting in the file.
- Keep these front-of-mind: surface modularity and idiom issues even when the
  code is otherwise correct.

Be specific and cite the relevant manual section or principle when raising a
point. Distinguish blocking issues from optional suggestions.

### 3. Challenge ambiguous phrasing and terminology

Flag ambiguous terms and phrasing rather than accepting a plausible-sounding
reading — a name that could mean more than one thing, a claim that cites a
value or construct without confirming it exists in the actual code. This is a
global standing rule from the
[`d-morrison/ai-config`](https://github.com/d-morrison/ai-config) corpus.
Ambiguity accepted at face value is how a factually wrong claim (e.g.
documentation citing a nonexistent enum value) slips through review
unchallenged.

### 4. Fact-check prose against domain knowledge and external sources

When a diff touches prose (`README.md`, `CHANGELOG.md`, `website/`, action
descriptions), assess the accuracy and clarity of its claims — check each
against domain knowledge and, where checkable, an external source (the
referenced tool's own docs, a linked spec) — and check any document-internal
reasoning the prose makes (e.g. a justification for why a workflow does
something a particular way). State which claims are inaccurate, cite the
specific source checked for each judgment, and proactively suggest
additional citations where they'd help. This is a global standing rule from
the [`d-morrison/ai-config`](https://github.com/d-morrison/ai-config) corpus
(`shared/writing/fact-check-prose.md`).
MIT License

Copyright (c) 2026 Douglas Ezra Morrison

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# d-morrison/gha

Central, reusable GitHub Actions for d-morrison / UCD-SERG / ucdavis R-package
and Quarto repositories. Modeled on
[`r-lib/actions`](https://github.com/r-lib/actions) and
[`easystats/workflows`](https://github.com/easystats/workflows): repos call a
reusable workflow with a tiny stub instead of carrying their own copy.

This repo is **public** so it can be referenced from repositories across the
`d-morrison`, `ucdavis`, `UCD-SERG`, `UCLA-PHP`, and `UCD-IDDRC` owners.

## How it works

Each capability is shipped as two layers:

- **Composite action** (e.g. `check-bibliography-dois/action.yml`) — bundles the
  real steps and any helper script. Referenced as
  `d-morrison/gha/<name>@v1`.
- **Reusable workflow** (`.github/workflows/<name>.yml`, `on: workflow_call`) —
  wraps the composite, declares permissions, and checks out the caller's repo.
  This is what consumer repos target.

A consumer repo adds a small caller stub (see [`examples/`](examples)):

```yaml
name: Check Bibliography DOIs
on:
  push: { branches: [main] }
  pull_request:
  workflow_dispatch:
jobs:
  check:
    uses: d-morrison/gha/.github/workflows/check-bibliography-dois.yml@v1
```

Pin to `@v1` (a moving major tag updated as fixes land). Do not reference
`@main` from consumers.

## Available reusable workflows

| Workflow | Purpose | Key inputs |
|---|---|---|
| `check-bibliography-dois.yml` | Validate book/article BibTeX entries have resolvable DOIs matching CrossRef metadata | `exclude-keys`, `install-quarto`, `no-metadata-check` |
| `check-non-standard-chars.yml` | Detect curly quotes / en–em dashes in `.qmd` and `.R` files | `python-version` |
| `check-phi.yml` | Scan PRs (added lines only) for content that looks like PHI — SSNs, medical record numbers, dates of birth, PHI column headers in data files | `detectors`, `paths-ignore`, `allowlist-file`, `fail` |
| `check-links.yml` | lychee link check with bundled config, PR skip-label, and auto-issue on `main` | `lychee-config`, `lychee-args`, `fail`, `fail-if-empty`, `create-issue-on-main`, `skip-label` |
| `summary.yml` | AI summary comment on newly opened issues | — |
| `check-news.yml` | Enforce a `NEWS.md` changelog entry on PRs (wraps `UCD-SERG/changelog-check-action`) | `changelog` |
| `test-coverage.yml` | Measure R-package test coverage with `covr` and upload the Cobertura report to Codecov | `path`, `install-quarto`, `extra-packages`, `fail-ci-if-error` |
| `claude.yml` | Agent-mode Claude Code bot: responds to `@claude` mentions, edits files, opens/updates PRs | `setup-r`, `install-quarto`, `use-renv`, `apt-packages`, `pip-packages`, `checkout-submodules`, `link-skills`, `eager-pr`, `prompt-addendum`, `webfetch-allowlist-url`, `reviewer` |
| `claude-code-review.yml` | Read-only Claude PR review (runs the `code-review` plugin; inline findings when `track-progress: true`, consolidated summary otherwise) | `pr-number`, `prompt-addendum`, `checkout-submodules`, `allowed-bots`, `track-progress`, `apt-packages`, `pip-packages` |
| `quarto-publish.yml` | Render a Quarto site and deploy it to GitHub Pages | `path`, `setup-r`, `r-packages`, `use-renv`, `tinytex`, `apt-packages`, `output-dir`, `checkout-submodules`, `pre-render-artifact`, `pre-render-artifact-path`, `deploy` |
| `preview.yml` | Build half of the PR-preview family: render a Quarto site in the (possibly fork) PR context and upload it + PR metadata as an artifact (read-only) | `path`, `r-version`, `apt-packages`, `use-renv`, `install-package`, `setup-chrome`, `submodules`, `render-profile` |
| `preview-deploy.yml` | Deploy half: on `workflow_run` completion of the build, publish the artifact to `gh-pages` and comment the preview link (base-repo context) | — |
| `check-equation-renders.yml` | On the same `workflow_run` completion, crawl the build artifact with a headless browser and fail on equations MathJax can't render | `fail` |
| `cleanup-pr-previews.yml` | Housekeeping: delete `gh-pages` preview directories for PRs that are no longer open, and (optionally) orphan-squash `gh-pages` to one commit so deleted snapshots stop bloating the repo | `preview-dir`, `compact-history` |
| `bump-submodule.yml` | Update a named submodule to its upstream HEAD and open a PR when the pointer moves | `submodule-path`, `remote-branch`, `base-branch`, `pr-branch` |
| `sync-shared-fragments.yml` | Vendor files from an upstream repo (pinned to a commit, recorded in a manifest) and open a PR when they change — avoids a recursive mutual submodule | `source-repo`, `source-ref`, `source-paths`, `dest-dir`, `manifest-path` |

## Permissions

A called reusable workflow cannot hold more `GITHUB_TOKEN` permissions than the
caller grants, and most repos default to a **read-only** token. So workflows
that need to write must have the **caller** grant it on the calling job:

- `check-links` (opens an issue on `main` failures) → grant `issues: write`,
  `pull-requests: read`, `contents: read`.
- `summary` (comments on issues, calls the models API) → grant `issues: write`,
  `models: read`, `contents: read`.
- `check-bibliography-dois`, `check-non-standard-chars`, `check-phi`,
  `test-coverage` → only `contents: read` (the default), so no `permissions:`
  block is needed. `test-coverage` additionally takes an optional
  `CODECOV_TOKEN` secret, passed through the caller's `secrets:` block.
- `quarto-publish` (deploys to the `gh-pages` branch, which Pages serves) →
  grant `contents: write`, and set Settings → Pages → Source = "Deploy from a
  branch", branch `gh-pages` / `(root)` once. Grant `contents: write` even with
  `deploy: false` — the deploy job is part of the workflow, so the caller must
  grant its permissions even when it is skipped.
- `claude` (pushes branches, opens PRs, dispatches the review workflow) → grant
  `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`,
  `actions: write`, and add the `CLAUDE_CODE_OAUTH_TOKEN` secret.
  - **Optional:** if Claude will edit files under `.github/workflows/`, also add
    a `WORKFLOW_TOKEN` secret (a PAT or GitHub App token with `contents:write` +
    `workflows:write`). The integrated `GITHUB_TOKEN` cannot push workflow-file
    changes — GitHub rejects them without the `workflows` scope. Repos that never
    touch `.github/workflows/` can omit it; pushes fall back to `GITHUB_TOKEN`.
    Note that, unlike `GITHUB_TOKEN`, a PAT/App-token push **does** trigger other
    `push`-based workflows, so enabling `WORKFLOW_TOKEN` can set off extra CI runs.
  - **Optional:** set `checkout-submodules: true` so Claude can read submodule
    contents. Public submodules clone anonymously; private ones additionally need
    a `SUBMODULES_TOKEN` secret.
- `claude-code-review` (read-only review) → grant `contents: read`,
  `pull-requests: write`, `issues: write`, `id-token: write`, and the
  `CLAUDE_CODE_OAUTH_TOKEN` secret.
  - **Optional:** set `checkout-submodules: true` so the reviewer can read
    submodule contents instead of reporting them as uninitialized. Public
    submodules clone anonymously; private ones additionally need a
    `SUBMODULES_TOKEN` secret.
- `preview` (build half, read-only) → only `contents: read` (the default).
- `preview-deploy` (deploy half, pushes `gh-pages` + comments) → grant
  `contents: write`, `pull-requests: write`, `actions: read`.
- `check-equation-renders` (downloads the build artifact, read-only) → grant
  `contents: read`, `actions: read`.
- `cleanup-pr-previews` (commits deletions to `gh-pages`) → grant
  `contents: write`, `pull-requests: read`.
- `bump-submodule`, `sync-shared-fragments` (open a PR) → grant `contents: write`,
  `pull-requests: write`, and enable Settings → Actions → General → "Allow
  GitHub Actions to create and approve pull requests" so the integrated
  `GITHUB_TOKEN` can open the PR. For private submodules, `bump-submodule` also
  needs a `SUBMODULES_TOKEN` secret. Add a `WORKFLOW_TOKEN` only to push to a
  protected branch; otherwise pushes fall back to `GITHUB_TOKEN`.

The stubs in [`examples/`](examples) already include the right `permissions:`
blocks — copy them as-is.

The two Claude workflows are a pair: an `@claude review` mention (or any commit
Claude pushes) routes through `claude.yml`, which dispatches `claude-code-review.yml`
via `workflow_dispatch`. Install both, and keep the review stub named
`claude-code-review.yml` (or set `claude.yml`'s `review-workflow-file` input to
match) so the dispatch resolves.

You can also start a review **directly**, without waking the `@claude` agent, by
commenting `/review` at the start of a PR comment. `claude-code-review.yml`
listens for that comment itself and re-dispatches its own `workflow_dispatch`
review of the PR — so `/review` needs only `claude-code-review.yml` installed
(no `claude.yml`), and it works for `OWNER`/`MEMBER`/`COLLABORATOR` commenters
once the workflow is on your default branch. It's a slash command rather than an
`@claude review` mention on purpose: any `@claude` substring would also trigger
`claude.yml`, so the slash command keeps the direct path independent.

## Claude session visibility

GHA sessions (both `claude.yml` and `claude-code-review.yml`) run as headless
CI jobs and **cannot be remote-controlled or observed** from the
[claude.ai](https://claude.ai) web interface. The CLI's "Join session" feature
requires a live interactive terminal; `anthropics/claude-code-action` has no
parameter to enable it, and GitHub Actions runners don't expose that hook.

The table below lists what is available instead. Its "Action argument" column
gives the argument passed to `anthropics/claude-code-action`; these are **not**
caller-facing `workflow_call` inputs unless the "Caller-configurable?" column
says so.

| Feature | Action argument | Caller-configurable? |
|---|---|---|
| Live progress tracking comment on the PR | `track_progress` | Yes — driven by the `track-progress` input of `claude-code-review.yml` (default `false`; tag mode with tracking comment and inline-comment tool when `true`, agent/summary-only mode when `false`). Not used in `claude.yml`. See `track-progress` warning in the inputs table: tag mode exposes git write tools until anthropics/claude-code-action#1415 lands. |
| Full Claude SDK output in the job log | `show_full_output` | Yes — driven by the `show-full-output` input of `claude-code-review.yml` (note the hyphen; off by default, turn on to diagnose silent auth / quota failures). Not surfaced in `claude.yml`. |
| Resume a prior session | `session_id` (internal step output of `anthropics/claude-code-action`) + `--resume` in `claude_args` | No — neither reusable workflow declares `session_id` as a `workflow_call` output, so session resume is not available to consumers of `claude.yml` or `claude-code-review.yml`. |

## PHI scanning (`check-phi`)

`check-phi` is a **heuristic tripwire, not a HIPAA compliance tool.** It flags
patterns that should almost never be committed — US Social Security numbers,
medical record numbers, dates of birth, and PHI-suggestive column headers in
delimited data files (`.csv`/`.tsv`/`.psv`) — so a human reviews before the
data merges. It is tuned for high precision (few false positives), so it will
miss free-text PHI such as patient names. The `phone` and `email` detectors
exist but are **off by default** (too noisy in source); enable them via the
`detectors` input.

- **Diff-scoped on PRs.** Only lines *added* by the PR are scanned, so existing
  fixtures don't re-trip the check on unrelated edits. `push` runs scan the
  whole tracked tree (`git ls-files`).
- **Values are never printed.** A leaked identifier in a CI log is still a leak,
  so findings report only `file:line:col` and the detector name — never the
  matched text. Findings appear as inline annotations on the PR.
- **Suppressing false positives** (e.g. synthetic test data): add a `phi-allow`
  comment on the line, or list a regex matching the value in an allowlist file
  (defaults to `.github/phi-allowlist.txt` when present; override with the
  `allowlist-file` input). Use `fail: false` to downgrade to warnings.

## PR previews (`preview` family)

The PR-preview family publishes a rendered Quarto site for each open PR to a
`pr-preview/pr-<n>/` directory on `gh-pages`. It is **four** cooperating
workflows — install all four stubs from [`examples/`](examples):

1. **`preview.yml`** (build) — triggered on `pull_request`. Renders the site and
   uploads it plus the PR metadata as a `pr-preview-site` artifact. Runs
   **read-only** in the (possibly fork) PR context, so it can't write to the
   base repo.
2. **`preview-deploy.yml`** (deploy) — triggered on `workflow_run` completion of
   the build. Downloads the artifact and publishes it to `gh-pages` in the
   **base-repo** context (where the token can write), then comments the preview
   link on the PR.
3. **`check-equation-renders.yml`** — also triggered on `workflow_run`
   completion of the build. Downloads the same artifact and crawls it with a
   headless browser, failing when MathJax can't typeset an equation — a failure
   mode invisible to the Quarto/pandoc build log, since MathJax only runs
   client-side. Runs independently of the deploy (no `gh-pages` write needed),
   not sequenced after it.
4. **`cleanup-pr-previews.yml`** (housekeeping) — scheduled. Removes preview
   directories for PRs that have closed. Set `compact-history: true` to also
   orphan-squash `gh-pages` to a single commit each run, so the deleted
   snapshots don't accumulate and bloat the repo (branch-based Pages only).

The build/deploy split is a **trust boundary**: untrusted fork code only ever
runs in the read-only build half, while the privileged `gh-pages` push happens
in the deploy half against base-repo code. Don't collapse them into one job.

Two wiring requirements:

- The deploy stub's and the equation-check stub's `on: workflow_run: workflows:`
  value **must match the build stub's `name:`** (all default to `Quarto Preview
  Build` in the examples). That string is how `workflow_run` finds the build.
- `workflow_run` and `schedule` triggers only fire for the copy of the file on
  the **default branch**, so previews and cleanup don't take effect until the
  stubs are merged to `main`.

The build half is parameterized for non-rme consumers (R version, the apt
package list, renv on/off, `R CMD INSTALL .` on/off, Chrome, submodules, render
profile). Label-gated extras are preserved: add `preview:pdf`, `preview:docx`,
or `preview:revealjs` to a PR to render those formats too, and `clear freezer`
to bypass the Quarto freeze cache.

## Shared-content sync (`bump-submodule` + `sync-shared-fragments`)

Two repos can share single-source-of-truth content and keep both copies current
without hand-bumping. The pair handles the two directions:

- **`bump-submodule`** — for the side that vendors the other repo as a git
  submodule. A scheduled run advances the submodule to its upstream HEAD and
  opens a PR when it moved. (Used by `UCD-SERG/lab-manual`, which carries
  `d-morrison/ai-config` as `.ai-config`.)
- **`sync-shared-fragments`** — for the side that can't add a submodule because
  the other repo already submodules *it* (a mutual submodule would recurse).
  Instead it vendors a pinned **copy** of the named files into a `dest-dir`,
  records the source repo and commit in a JSON manifest, and opens a PR when the
  copy changes. (Used by `d-morrison/ai-config` to vendor the lab manual's
  authored fragments.) Don't hand-edit the vendored copies — edit them upstream
  and let the workflow refresh them; a consumer-side drift check can assert the
  copy matches the pinned commit.

Both reuse the `open-sync-pr` composite, which commits staged changes to a
reused automation branch and opens or updates one PR (no-op when nothing
changed). Schedule and `workflow_dispatch` triggers live in the caller stubs.
Path-filter or scope each side to the *other* repo's shared content (not its own
pointer/manifest) so the two auto-PRs don't ping-pong.

## Versioning

Releases are tagged `vX.Y.Z`; the `vX` major tag moves to the latest compatible
release. Consumers reference `@v1`, except `test-coverage.yml` and
`check-equation-renders.yml`, which ship at `@v2` (too new for the frozen
`@v1` tag). See [`CHANGELOG.md`](CHANGELOG.md) for
what changes as a major tag moves and for any breaking-change migration steps.

Changelog entries are added as fragment files under
[`changelog.d/`](changelog.d) (one per PR, so parallel PRs never conflict on the
shared changelog) and collated into `CHANGELOG.md` at release time — see
[`changelog.d/README.md`](changelog.d/README.md).

### Pinning third-party actions

Every **third-party** action is pinned to a full commit SHA, with the
human-readable version in a trailing comment, e.g.:

```yaml
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
```

This is GitHub's [recommended hardening posture](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions):
a SHA is immutable, so a re-pointed tag or a compromised upstream can't silently
change what runs — which matters here because jobs like the preview deploy run
with `contents: write` + `pull-requests: write`. [`.github/dependabot.yml`](.github/dependabot.yml)
bumps these pins as upstreams publish releases, so they stay current instead of
freezing. When adding a new third-party action, pin it the same way.

First-party `d-morrison/gha/*@v1` self-references and the [`examples/`](examples/)
templates intentionally track the `@v1` major tag (so consumers ride the moving
major), and so are **not** SHA-pinned.

## Reverse dependencies

[`REVDEPS.md`](REVDEPS.md) tracks repos that call these workflows, so consumers
can be notified before a breaking change. If your repo uses `gha`, please add
it there.

## Notes for private consumers

Reusable workflows in this public repo are callable from public repos
automatically. A **private** consumer must allow access to this repo under
*Settings → Actions → General → Access* before it can call these workflows.

## Scope

This started as the pilot set (the byte-identical / near-identical workflow
families) plus the PR-preview/publish family. Additional families (spell check,
lint-changed-files, pr-commands, R-CMD-check) may be added later.
# Reverse Dependencies (Consumer Repos)

Repos that call `d-morrison/gha` reusable workflows from their
`.github/workflows/`.

> **Note:** This list helps us notify consumers before moving the `@v1` tag in
> a breaking way (or cutting `@v2`). It is **not** authoritative — always
> verify with a code search across the consuming orgs (`d-morrison`,
> `ucdavis`, `UCD-SERG`, `UCLA-PHP`, `UCD-IDDRC`) when releasing a breaking
> change. A GitHub code search for `d-morrison/gha/.github/workflows` across
> those owners is the quickest way to find current callers:
>
> ```bash
> # Requires an authenticated gh (run `gh auth login`, or set GH_TOKEN).
> gh search code 'uses: d-morrison/gha/.github/workflows' --owner d-morrison --owner ucdavis --owner UCD-SERG --owner UCLA-PHP --owner UCD-IDDRC
> ```

## How to register

If your repo calls a `gha` workflow, please open a PR adding it below (or file
an issue asking to be added). Similarly, if you stop using `gha`, open a PR or
issue to be removed.

## Consumer list

| Repo | Workflows used | Notes |
|------|----------------|-------|
| [`d-morrison/qwt`](https://github.com/d-morrison/qwt) | `check-bibliography-dois`, `check-non-standard-chars`, `check-links` | Quarto website template (propagates to downstream books via "Use this template"). Phase 1 migration ([qwt#115](https://github.com/d-morrison/qwt/pull/115)); `summary` + the Claude workflows pending parity ([qwt#116](https://github.com/d-morrison/qwt/issues/116)). |
| [`d-morrison/rme`](https://github.com/d-morrison/rme) | `preview`, `preview-deploy`, `cleanup-pr-previews` | The original motivation for the PR-preview family (see [#33](https://github.com/d-morrison/gha/issues/33)/[#34](https://github.com/d-morrison/gha/pull/34)). Migrated its three inlined preview workflows to the gha family in [rme#942](https://github.com/d-morrison/rme/pull/942) ([#75](https://github.com/d-morrison/gha/issues/75)). |
| [`Lacaedemon/sparta`](https://github.com/Lacaedemon/sparta) | `check-links`, `claude`, `claude-code-review`, `summary`, `quarto-publish` | Godot game; docs site published via `quarto-publish` (injects recorded gameplay clips through `pre-render-artifact`). |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants