skribbl
productpricingfree!questionswriting
download
guides · 9 August 2026 · 6 min read

Running Claude Code in CI/CD

The one setting that must never be on when the trigger is a stranger’s PR.

skribbl/writing/guides
headless, scoped, and watched

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.

Headless mode still respects the permission system. It just resolves prompts automatically according to the mode and any allow/deny rules you configured, instead of asking a human. It is not the same thing as skipping permissions entirely -- that is a separate, much more dangerous flag, covered below.

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:

ANTHROPIC_API_KEYA standard API key. Simplest option for a single repo.
CLAUDE_CODE_OAUTH_TOKENA token minted from an existing Claude subscription (`claude setup-token`). Usage draws from that plan rather than separate API billing.
# 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.

This is not a hypothetical about the model deciding to misbehave. It is a much more mundane supply-chain problem: prompt injection via file contents, or a workflow misconfigured in a way that lets a fork's 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_request trigger 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_target changes 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. acceptEdits auto-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 / --disallowedTools or a project-level settings file.
  • Give the workflow job only the GitHub permissions it needs. The permissions: block in the example above grants contents: write and pull-requests: write and nothing else -- no issues: 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: 15 in 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.

Prompt caching helps here if your invocations share a stable system prompt or repeated context across runs in the same job, but each CI job typically starts a fresh process with no cache to inherit from the last run -- don't assume caching is quietly saving you money in this setup unless you have checked.

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.

Skribbl watches CI-triggered Claude Code runs the same way it watches interactive ones -- each run shows up on the canvas with live token spend, so a job that quietly balloons past its turn budget is visible before it shows up as a surprise on the invoice. See the docs or download to try it.
READ NEXT
Claude Code headless mode: scripting an agentThe same agent, minus the terminal, plus a JSON stream you can actually parse.5 minClaude Code hooks: a practical guide with examplesEvery event, the payload it carries, and the exit code that blocks a tool call.12 minA multi-agent code review pipeline, builtFindings have to survive an agent whose only job is to refute them.6 min
ON THIS PAGE
why run an agent in CI at allheadless mode, in one commanda working GitHub Actions workflowthe credential goes in as a repo secretnever bypass permissions on a fork PRscoping what the agent can touchcost control for unattended runsthree things worth automating
run them on a canvasSkribbl puts every agent, its terminal and what it is spending on one board. macOS, one day free.

get the next one by email.

One email when there is something worth reading. Unsubscribe is one click and it is in every issue.

download
productpricingdocsquestionswhat it iscomparereleaseswritingnewsletterlaunchesprivacycancel
give them infinity.© skribbl