Guides

Running Claude Code in CI and GitHub Actions safely

Claude Code's non-interactive mode (claude -p) runs the same agent loop as an interactive session, but nobody is watching to click "Allow." In CI, that means every permission decision has to be made in advance, either by pre-approving specific tools or by explicitly denying everything else.

Two ways to run it in CI

Headless basics: claude -p

Add --bare in CI so the run doesn't pick up a contributor's local hooks, MCP servers, or CLAUDE.md and produce a different result on every machine. Bare mode skips your OAuth login too, so set ANTHROPIC_API_KEY instead:

export ANTHROPIC_API_KEY=sk-ant-...
claude --bare -p "Run the test suite and summarize any failures" \
  --allowedTools "Bash(npm test),Read" \
  --output-format json

--allowedTools is the whitelist: only the tools and command prefixes listed run without a prompt. Anything not covered is denied outright in bare mode's default (Manual) permission behavior — there's no one there to answer a prompt, so an unlisted tool call just fails.

Turn off permission prompts explicitly

For a fully unattended run, pair a permission mode with --permission-prompts none so nothing waits on an answer that will never come:

claude --bare -p "Update dependency pins and run the tests" \
  --permission-mode auto \
  --permission-prompts none \
  --output-format json

With the flag set, anything that would have prompted is denied instead of hanging, and Claude is told not to retry it. Permission rules and the mode you pick are still checked first — the flag only resolves what's left over.

Reading the result

--output-format json returns a single JSON object with the result text, session ID, and cost. Pull the pieces you need with jq:

result=$(claude --bare -p "Summarize this PR's risk" --output-format json)
echo "$result" | jq -r '.result'
cost=$(echo "$result" | jq -r '.total_cost_usd')

Claude Code exits 0 on success and non-zero on failure, so a CI step can branch on $? without parsing output at all. A SIGTERM (a job timeout, for instance) exits with code 143 and leaves the in-progress turn unrecorded.

The official GitHub Action

For GitHub specifically, anthropics/claude-code-action wraps the same underlying SDK and handles auth, the @claude trigger phrase, and posting results back to the PR. Run /install-github-app from Claude Code locally for guided setup, or add the workflow by hand:

name: Claude Code
on:
  issue_comment:
    types: [created]
jobs:
  claude:
    if: contains(github.event.comment.body, '@claude')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 1
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

Store the key as a repository secret, never inline in the workflow file. With no prompt input, the action waits for @claude mentions (interactive mode). Add a prompt input and it runs unattended on any trigger, including schedule (automation mode) — that mode still checks that the triggering actor has write access before doing anything.

Scoping what an automated run can touch

Pass CLI flags through claude_args, the same way you'd pass them to claude -p directly:

claude_args: |
  --max-turns 5
  --model claude-sonnet-5
  --allowedTools "mcp__github__list_commits,mcp__github__list_issues"

--max-turns caps how much a single run can do, which limits both runaway loops and their bill. Keep the workflow's own GitHub token permissions narrow too — a review job that only reads PRs doesn't need contents: write.

Don't rely on the allowlist alone

LayerWhat it stops
--allowedTools / permissions.allowTools and command prefixes outside the list
--permission-prompts noneRuns hanging on a prompt nobody can answer
A PreToolUse hookA destructive pattern inside an otherwise-allowed command, like Bash(npm run *) matching npm run reset-db -- --force

An allowlist entry like Bash(git *) also approves git push --force; see Claude Code permissions for narrower rule syntax, and blocking dangerous commands for a hook that catches destructive commands a prefix rule would otherwise wave through. The free hook builder generates that kind of guard script from a plain-language description if you'd rather not write the regex by hand.

Watch the bill

A CI job that runs on every push, times out slowly, or lacks --max-turns can rack up cost quietly in the background. Reducing Claude Code token usage covers picking cheaper models for mechanical CI tasks and reading per-run cost out of --output-format json.

Skip the setup: get the tested versions

Keelwork bundles 10 workflow skills, 5 tested safety hooks (including a full guard-bash and a secret scanner), 3 subagents and 5 CLAUDE.md templates, with a one-command installer that safely merges into your settings.

Get Keelwork — $24 →