rules/claude-github-workflows.md
Guidelines for the LLM-driven workflows in .github/workflows/ that invoke anthropics/claude-code-action or anthropics/claude-code-base-action (e.g., closed-issue-comment.yml, claude-triage.yml, claude-pr-review.yml). Both actions wrap the same claude CLI under the hood, so settings/permission behavior is identical between them.
If a workflow's behavior depends on a deterministic check (identity comparisons, label presence, file paths, actor type, etc.), do the check in a workflow-level if: condition and split into separate jobs — do not leave it to the prompt.
Why: LLMs can conflate branches when the comment/PR body @mentions or describes the "other" party. A prior bug (see closed-issue-comment.yml history, dyad-sh/dyad#3228): the prompt told Claude "if COMMENT_AUTHOR == ISSUE_AUTHOR do X, else do Y," but when a maintainer closed an issue with a comment that mentioned @original-author and described the symptom, Claude fell into the author branch and re-opened the issue.
How to apply:
github.event.comment.user.login vs github.event.issue.user.login (and similar) in the job if: block, not the prompt.gh directly.github.event.*.user.type != 'Bot' to prevent bot-comment loops when the same workflow can be triggered by its own output.When a Claude workflow needs write credentials, prefer a two-job shape: the Claude job runs with read-only permissions and uploads a constrained JSON/Markdown artifact, then a separate needs: job downloads the artifact, checks out trusted helper scripts from github.sha, validates the artifact, creates the GitHub App token, and performs deterministic GitHub mutations.
When a headless Claude job must write local handoff artifacts, exclude project/local settings with claude_args --setting-sources user, explicitly pre-approve its inspection tools, and scope Edit(...) to the output directory via --allowedTools. Without the setting-source restriction, the repository's broad project allowlist merges with the scoped rule and defeats the intended boundary. After the action, verify every mandatory file with test -s before upload: actions/upload-artifact's if-no-files-found: error still succeeds when only one of several listed paths exists, and Claude Code can report a successful session after denied tool calls.
.claude/settings.json merges into CIBoth claude-code-action and claude-code-base-action read .claude/settings.json from the workspace after actions/checkout, and the project's file is committed (tracked in git). permissions.allow arrays merge across scopes — they do not replace each other. From the Claude Code docs: "Array settings merge across scopes. When the same array-valued setting (such as permissions.allow) appears in multiple scopes, the arrays are concatenated and deduplicated, not replaced." (source).
When upgrading the standalone claude-code-base-action, do not infer its bundled Claude Code version from the latest release tag: the repository can continue receiving immutable sync commits after tagged releases stop. Inspect action.yml at the exact commit, pin that SHA, and annotate the bundled CLI version so model compatibility is reviewable.
This has two consequences that bite in CI:
allowed_tools action input is additive, not authoritative. A workflow that sets allowed_tools: "Read,Glob,Grep,Bash(git log:*)" still inherits every entry in the project's .claude/settings.json — Bash(git:*), Bash(gh pr create:*), Bash(npm run:*), Bash(rm -f ...), etc. The narrow list looks tight but isn't.pull_request_target + PR head, or workflow_run), the .claude/settings.json is attacker-controlled (modulo any author allowlist). A hostile PR can ship a maximally permissive settings file.Why: the project file is broad on purpose — it exists for local dev, where the developer-in-the-loop and the on-disk permission hooks (.claude/hooks/) compensate. CI has neither.
How to apply (layered defenses, pick what fits the job):
actions/checkout entirely when the agent doesn't need repo contents (classification, summarization, structured-output jobs). Without checkout, .claude/settings.json is never in the workspace.--setting-sources user in claude_args. This is more reliable than deleting .claude/settings*.json before claude-code-action, because newer action versions restore sensitive configuration paths from the trusted base ref before launching Claude. For claude-code-base-action, which does not restore project configuration, stripping the files after checkout remains an option.settings: input with an explicit deny list. This merges too, but deny beats allow, so it's an additional belt-and-suspenders layer. The action's settings input accepts a JSON string or a file path. Example for a tool-less classifier:
settings: |
{
"permissions": {
"allow": [],
"deny": ["Bash", "Edit", "Write", "Read", "NotebookEdit", "WebFetch", "WebSearch"]
}
}
closed-issue-comment.yml is the reference example: no checkout, defensive rm tripwire (in case checkout gets re-added), and an inline deny-all settings:.Verify before merging a new claude-code-action workflow: mentally compute the effective allowlist as project .claude/settings.json ∪ allowed_tools input ∪ inline settings allow, minus any deny. If that union is wider than the job actually needs — especially if the job handles untrusted input or checks out a fork — apply the mitigations above.