Study guides / CCDV-F / Domain 7

Claude Code · Lesson 2 of 2

7.2 - Running Claude Code in CI/CD

Run Claude Code non-interactively in a pipeline with structured output a script can parse, scoped tool permissions, an API-key auth path, and a clean session per run.

Claude Code's non-interactive/headless mode runs a single prompt to completion without a human at the keyboard — the shape a CI pipeline needs, e.g. an automated PR review step. You invoke it with claude -p "<prompt>" (or --print); instead of dropping into the interactive REPL, Claude Code executes the task and exits, returning a non-zero exit code on failure so a pipeline step can branch on it. Requesting structured (JSON) output rather than free text makes the result something your pipeline script can parse and act on programmatically, the same structured-output discipline from Lesson 4.2 applied to Claude Code's own output.

Output formats: text, json, stream-json

The --output-format flag controls what headless mode returns. text (the default) prints the final response as plain text — fine for a human reading a log, fragile for a script. json returns a single JSON object once the run completes, including the final result, the session ID, and usage/cost data — this is what a CI script should parse to decide pass/fail. stream-json emits newline-delimited JSON events as the run progresses (tool calls, intermediate results, the final message) rather than waiting for completion, which matters for a long-running CI step where you want to stream progress into a log rather than see nothing until the very end.

Scoping tool access for an unattended run

A human is not present in CI to approve an unexpected permission prompt, so the permission mode for that run has to be decided in advance rather than negotiated interactively. --allowedTools and --disallowedTools let you pass an explicit tool allowlist/denylist on the command line for that invocation, scoping exactly what the run can touch (e.g. allow reading and running tests, disallow arbitrary shell). --permission-mode selects a broader policy: plan has Claude produce a plan without executing anything, acceptEdits auto-approves file edits but still gates other risky actions, and bypassPermissions skips permission checks entirely. That last one removes the safety net a human approval or a deny rule would normally provide, so it should only ever run inside an ephemeral, sandboxed CI container with no access to real credentials or production systems — never on a developer machine or a runner with broad access, since there is no human left to catch a destructive action before it happens.

Common exam distractor

Parsing Claude Code's free-text output with fragile string matching in a CI script is a trap; requesting structured JSON output (--output-format json) is the reliable way to make the result machine-actionable. A related trap: treating --dangerously-skip-permissions/bypassPermissions as simply a convenience for CI rather than a real security tradeoff that demands a sandboxed, isolated environment.

Session isolation between runs

Each CI run should start from a clean, isolated session — not accumulated context from a previous pipeline run — so results are reproducible and one run's review doesn't get contaminated by an unrelated earlier one. This is the CI-specific case of the same fresh-session principle from Lesson 7.1: a plain claude -p invocation without --resume or --continue already starts fresh, and CI pipelines should deliberately avoid those two flags rather than reach for them out of habit, since they resume a prior session's context, which is exactly what a reproducible pipeline run does not want.

Authentication: API key, not interactive login

Interactive claude login opens a browser for OAuth — there is no browser and no human to complete that flow on a CI runner. Headless CI instead authenticates via the ANTHROPIC_API_KEY environment variable, populated from the CI provider's secret store, never committed to the repo or hardcoded into a workflow file.

Wiring it into a pipeline

The official anthropics/claude-code-action GitHub Action wraps headless mode for the common case — triggering on an @claude mention in an issue or PR comment, or on a workflow event — and needs the repo permissions the task requires (e.g. contents: write, pull-requests: write) plus the API key as a repository secret. For anything more custom, a plain shell step invoking claude -p directly works on any CI provider:

- name: Claude review
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
  run: |
    claude -p "Review the diff in this PR for bugs and style issues. Output findings as JSON." \
      --output-format json \
      --allowedTools "Read,Grep" \
      --max-turns 8 > review.json
    node scripts/check-review.js review.json

--max-turns bounds how many agentic turns a single headless run can take, which matters in CI both for wall-clock time and for cost — an unattended run with no turn cap can, in principle, keep working far longer (and far more expensively) than intended before returning.

Exam traps

Practice question

A team wants an automated CI step where Claude Code reviews a pull request's diff and the pipeline script decides whether to block the merge based on the result. What two design choices make this reliable?

  • A Interactive mode with a human watching, and free-text output parsed with regex.

    CI needs a non-interactive run with no human at the keyboard, and free-text regex parsing is fragile compared to structured output.

  • B Non-interactive (headless) mode for the run itself, and structured JSON output the pipeline script can parse reliably. Correct

    Headless mode fits CI's no-human-present requirement, and structured output is what makes the result reliably machine-actionable for the merge-blocking decision.

  • C A single long-lived session shared across all future PR reviews, to save on session startup cost.

    Sharing a session across unrelated PR reviews risks context contamination between runs and breaks reproducibility.

  • D Extended thinking disabled entirely, since CI environments can't display thinking output.

    Thinking availability isn't a CI-specific constraint, and it isn't one of the two choices that make this design reliable.

Build exercise: Run Claude Code headlessly in a pipeline-shaped setup

Intermediate · 40 minutes

You'll practice:

  1. Run Claude Code in non-interactive mode against a small diff or file, requesting --output-format json, and parse the result in a small script.

    This is the exact shape a CI step needs, built and verified locally before it ever runs in a real pipeline.

    You should see: A single JSON object printed to stdout containing the result text, session ID, and usage data, which your script can load and branch on.

    Hints
    1. Which flag switches Claude Code from the interactive REPL to a single run-to-completion invocation, and which flag controls the shape of what it prints?
    2. Use -p (or --print) with the prompt as an argument, and pass --output-format json so the whole run's result comes back as one parseable JSON object instead of free text.
    3. claude -p "List any TODO comments left in src/ as a JSON array of {file, line, text}" --output-format json > result.json
      node -e "const r = require('./result.json'); console.log(typeof r.result)"
  2. Re-run the same prompt with --allowedTools restricted to read-only tools (e.g. Read and Grep) and confirm Claude cannot edit files during that run.

    An unattended run has no human to approve a surprise permission prompt, so the tool boundary has to be set explicitly before the run starts, not negotiated during it.

    You should see: The run completes using only read operations; if the prompt is changed to ask for a file edit, the edit attempt is refused rather than silently allowed.

    Hints
    1. What CLI flag lets you pass an explicit tool allowlist for a single headless invocation?
    2. Pass --allowedTools with a comma-separated list of exactly the tools this run should be permitted to use - anything not listed is unavailable, no prompt involved.
    3. claude -p "Summarize any code smells in src/utils.js" --output-format json --allowedTools "Read,Grep" > result.json
  3. Write a GitHub Actions workflow step that runs the headless command with ANTHROPIC_API_KEY sourced from repository secrets, not hardcoded.

    This is what makes the local experiment actually usable in a real pipeline - CI has no browser for interactive login, so the API key has to come from the platform's secret store.

    You should see: A workflow YAML step that references ${{ secrets.ANTHROPIC_API_KEY }} as an env var and runs claude -p as a shell command, with no key ever written into the file itself.

    Hints
    1. How does a GitHub Actions step usually reference a stored secret without ever printing its value into the workflow file?
    2. Set env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} on the step, then call claude -p inside a run: block, exactly as you did locally.
    3. - name: Claude PR review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          claude -p "Review this PR diff for bugs" --output-format json --allowedTools "Read,Grep" > review.json
          cat review.json
  4. Add --max-turns to the invocation and verify (by checking the JSON output's turn/usage data) that the run stayed within the cap.

    An unattended run with no turn cap can keep working, and keep spending, far longer than intended with no human present to notice and stop it - the cap is a safety net, the same principle as the iteration-cap discipline from Domain 1, applied to a CI budget.

    You should see: The run completes normally well under the cap for a simple prompt, and the JSON output's usage/turn data confirms it.

    Hints
    1. What flag bounds how many agentic turns a single headless run is allowed to take before it's forced to stop?
    2. Add --max-turns with a small integer appropriate to the task's complexity, then inspect the returned JSON for a turn count or usage field to confirm the run finished well inside that bound.
    3. claude -p "Review this PR diff for bugs" --output-format json --allowedTools "Read,Grep" --max-turns 6 > review.json
      node -e "const r=require('./review.json'); console.log(r.num_turns ?? r.usage)"
  5. Add a final script step that reads the parsed JSON, checks for an 'issues found' condition, and exits non-zero to fail the pipeline when issues are present.

    The whole point of structured output is that a script can act on it - the pipeline needs an actual pass/fail decision, not just a log a human reads afterward.

    You should see: The CI job fails (non-zero exit) when the parsed result reports issues, and succeeds when it doesn't, without any human reading the output to decide.

    Hints
    1. Once you have parsed JSON, what does a shell script or CI step need to do to actually fail the job, not just print a warning?
    2. In your parsing script, check whatever field your prompt asked Claude to populate (e.g. an issues array) and call process.exit(1) when it's non-empty, process.exit(0) otherwise - a non-zero exit code is what fails a CI job.
    3. const r = require('./review.json');
      const issues = r.issues ?? [];
      if (issues.length > 0) {
        console.error(`Blocking merge: ${issues.length} issue(s) found`);
        process.exit(1);
      }
      process.exit(0);

Sources