A GitHub Actions workflow that runs Claude Code headlessly to fix a failing test, review a PR diff, or write a changelog entry, triggered on workflow_dispatch or a PR event. The repo-secret setup, why bypassPermissions has no business running against an untrusted fork PR, and how to keep a CI run from running up an unwatched bill.
why run an agent in CI at all
Most Claude Code use is interactive: a terminal, a person watching the diff, a person hitting enter on each tool call. CI is the opposite shape. Nobody is watching, the job has to finish or fail cleanly, and it runs on every push or on a schedule rather than once when a person decides to invoke it. That is a legitimate but different use case, and it is what headless mode is for.
The realistic list of jobs for an agent in CI is narrower than it sounds. Auto-fixing a failing test on a PR branch. Reviewing a diff and posting comments before a human looks at it. Drafting a changelog entry from the commits since the last tag. All three share a shape: a well-scoped, mechanically checkable task with a clear stopping point. An agent that is told to "improve the codebase" on every push is not a CI job, it is a recurring bill with no exit condition.
headless mode, in one command
Headless mode means non-interactive: give Claude Code a prompt on the command line, let it run tool calls under a permission policy you set up front, and get an exit code and output back instead of a live session. The flag that does this is -p (print mode):
claude -p "Fix the failing test in tests/test_auth.py, run the test \
suite to confirm it passes, and stop" \
--permission-mode acceptEdits \
--output-format json--permission-mode acceptEdits tells Claude Code to apply file edits and run allowed tools without pausing for interactive approval, since there is no terminal to approve anything from. --output-format json gets you structured output -- the final result plus usage numbers -- that you can parse in a later step instead of scraping stdout.
a working GitHub Actions workflow
Here is a skeleton that reacts to workflow_dispatch (a manual button) and to a specific label on a pull request, and runs Claude Code against the checked-out branch to fix a failing test:
name: claude-fix-failing-test
on:
workflow_dispatch:
inputs:
instruction:
description: What should Claude fix
required: true
default: Fix the currently failing test
pull_request:
types: [labeled]
jobs:
agent-fix:
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'claude-fix'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install -g @anthropic-ai/claude-code
- name: Run Claude Code
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
claude -p "${{ github.event.inputs.instruction || 'Fix the failing test, run the suite to confirm, then stop.' }}" \
--permission-mode acceptEdits \
--max-turns 15 \
--output-format json > claude-output.json
- name: Surface usage in the job summary
run: |
jq -r '"tokens: " + (.usage.input_tokens|tostring) + " in / " + (.usage.output_tokens|tostring) + " out"' \
claude-output.json >> "$GITHUB_STEP_SUMMARY"
- name: Commit and push if changed
run: |
git config user.name "claude-code-bot"
git config user.email "actions@users.noreply.github.com"
git diff --quiet || (git add -A && git commit -m "Fix failing test via Claude Code" && git push)Note the guard on the job: it only runs on an explicit manual dispatch or a specific label, not on every push and not on every PR event. That is a cost control as much as a safety control -- see below.
the credential goes in as a repo secret
Claude Code authenticates the same way in CI as anywhere else: an API key or an OAuth token, read from the environment. Store it as a repository or organization secret and reference it in the workflow, never inline it:
# Settings -> Secrets and variables -> Actions -> New repository secret
# name: ANTHROPIC_API_KEY value: sk-ant-...
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}Scope the secret to the repositories that actually need it. An organization-wide secret available to every repo, including ones with lightly reviewed contributor access, is a wider blast radius than most teams intend to grant.
never bypass permissions on a fork PR
This is the one mistake in this space that actually costs people something, so it gets stated plainly rather than folded into a bullet list: a workflow triggered by a pull request from a fork is running on code you did not write and have not reviewed. If that workflow checks out the fork's branch, runs Claude Code against it with --dangerously-skip-permissions, and the job also has access to your repo secrets, you have handed an agent unrestricted tool use inside an environment that already holds your API key -- driven by instructions an attacker can put directly in the PR diff or a comment.
pull_request_target checkout run arbitrary code with your secrets in scope. The fix is procedural, not a smarter model.- Default GitHub Actions behavior already helps. A plain
pull_requesttrigger from a fork does not expose repo secrets to the job by default. Verify this is still true for your workflow rather than assuming it --pull_request_targetchanges that behavior and does expose secrets, which is exactly why it needs care. - If you use `pull_request_target`, do not check out the fork's code into a step that also runs the agent with write access. If you must inspect the diff, read it via the API rather than checking out and executing against the branch.
- Never combine `--dangerously-skip-permissions` with a fork-triggered job. Keep a real permission mode (
acceptEdits, or a custom allow/deny list) even when you trust the contributor -- the trigger, not the contributor, is what determines the risk. - Gate agent runs on fork PRs behind a maintainer action -- a label a maintainer applies, or a manual
workflow_dispatch-- rather than letting the fork PR trigger the agent automatically.
scoping what the agent can touch
Beyond the fork case, permission scoping in CI is worth being deliberate about even on your own branches, because there is no human in the loop to catch a tool call going somewhere unintended.
- Prefer `acceptEdits` over `--dangerously-skip-permissions` as the default for any CI job.
acceptEditsauto-approves file edits and the tools you have allowed, but it still runs inside the permission system -- it is not the same as disabling it. - Use an allow/deny list for anything the job should never do, most commonly denying
Bash(git push --force)and network access outside what the task needs, via--allowedTools/--disallowedToolsor a project-level settings file. - Give the workflow job only the GitHub permissions it needs. The
permissions:block in the example above grantscontents: writeandpull-requests: writeand nothing else -- noissues: write, no admin scopes, because the task does not need them. - Run in a fresh, disposable container or job rather than a long-lived self-hosted runner with accumulated state, so a bad run cannot leave residue for the next one.
cost control for unattended runs
An interactive session has a natural cost ceiling: a person is watching, and stops when the task is done or clearly going sideways. A CI job has no such ceiling by default -- it runs to its timeout or its turn limit, on every trigger, unattended. That is a different risk shape from interactive spend, and it is worth treating as an operations problem, not just a line on a pricing page.
- Cap turns explicitly.
--max-turns 15(or whatever fits the task) bounds how many agent-tool round trips a single invocation can take, so a stuck loop fails fast instead of running to the job timeout. - Set a job-level timeout too.
timeout-minutes: 15in the workflow is a second, independent backstop -- turn limits and wall-clock limits fail differently, and you want both. - Trigger narrowly. A label a maintainer applies, or manual
workflow_dispatch, costs you nothing until someone chooses to run it. Firing on every push or every PR event multiplies the same spend by however often those events happen, most of which will not need an agent. - Pull usage out of the JSON output on every run. The example workflow writes input/output token counts to the job summary. That is a cheap habit, and it is what turns a cost spike into something you notice in a job summary rather than at the end of a billing cycle.
a rule-of-thumb calculation, not a measurement
This is illustrative arithmetic, not a benchmark: if a fix-the-test run averages, say, 20,000 input tokens and 3,000 output tokens against Claude Sonnet 5 at $3 / $15 per million tokens, one run costs roughly six cents. That looks trivial until you multiply it by a trigger that fires on every push across a busy repo -- a hundred pushes a day is six dollars a day on a task most of those pushes did not actually need. The fix is the triggering discipline above, not a cheaper model by default.
three things worth automating
The brief for this pattern names three tasks. Each fits the "narrow, checkable, bounded" shape from the top of this post.
auto-fix a failing test
Trigger on a label a maintainer applies after CI goes red. Prompt: point at the failing test file and the test command, ask Claude to fix the source (not the test, unless the test is wrong) and re-run the suite to confirm before stopping. Commit back to the PR branch, don't open a new one -- keep the diff attributable to the PR under review.
review a PR diff
Trigger on pull_request (not _target, since this only needs to read the diff, not check out and execute the branch). Prompt Claude with the diff and ask for a structured list of findings, then post them as a PR comment via the GitHub CLI or API in a separate step -- keep the agent's job as generating text, and a plain script's job as posting it.
generate a changelog entry
Trigger on a release tag or manual dispatch. Prompt: summarize the commits since the last tag into a changelog entry in the project's existing format, write it to CHANGELOG.md, and stop -- no code edits, no test runs, which means this job can run with a narrower permission set than the other two.