Module 1: Claude Code in GitHub Actions

Your First YAML Workflow with Claude Code

Your First YAML Workflow with Claude Code

Overview

This is the capsule where Claude Code stops being a local tool and starts being an automatic agent. You're going to build, step by step, your first GitHub Actions workflow that runs Claude Code on every pull request — without you having to open anything, without it depending on your memory.

By the end of the capsule, you'll be able to write a YAML workflow from scratch that triggers on PR events, runs the Claude Code headless SDK, and produces verifiable output. It's not theory — it's an executable YAML you copy, push, and watch run in your repository.


Anatomy of a GitHub Actions Workflow

Before writing code, a mental image: a GitHub Actions workflow has three hierarchical levels.

WORKFLOW (a .yml file in .github/workflows/)
├── name: human-readable name of the workflow
├── on: when it triggers (triggers)
└── jobs:
    ├── JOB-1
    │   ├── runs-on: which machine it runs on (ubuntu, macos, windows)
    │   ├── steps:
    │   │   ├── STEP-1 (e.g. checkout the code)
    │   │   ├── STEP-2 (e.g. set up Python)
    │   │   ├── STEP-3 (e.g. run Claude Code)
    │   │   └── STEP-N
    │   └── env / outputs / etc.
    ├── JOB-2 (can run in parallel or wait for JOB-1)
    └── ...

Workflow is the whole file. Job is a set of steps that run on the same machine. Step is the atomic unit — an action or a shell command.


The Minimal Working Workflow

We start with a workflow that does one single thing: when a PR is opened, it runs Claude Code to analyze the diff and publishes the result to the logs.

Create the file .github/workflows/claude-code-analysis.yml:

name: Claude Code Analysis

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install Claude Code SDK
        run: pip install anthropic

      - name: Get PR diff
        id: diff
        run: |
          git diff origin/${{ github.base_ref }}...HEAD > pr_diff.txt
          echo "diff_size=$(wc -l < pr_diff.txt)" >> $GITHUB_OUTPUT

      - name: Run Claude Code analysis
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: python .github/scripts/analyze_pr.py

And create the script .github/scripts/analyze_pr.py:

"""Basic analysis of a PR with the Claude Code SDK."""
import os
from pathlib import Path
from anthropic import Anthropic

client = Anthropic()  # reads ANTHROPIC_API_KEY from the environment

diff_text = Path("pr_diff.txt").read_text()

if not diff_text.strip():
    print("No changes to analyze.")
    exit(0)

response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=2000,
    messages=[
        {
            "role": "user",
            "content": (
                "Analyze this pull request diff. "
                "Identify:\n"
                "1. What changes functionally.\n"
                "2. Possible problems (bugs, edge cases, security).\n"
                "3. Concrete suggestions for improvement.\n\n"
                f"Diff:\n```\n{diff_text}\n```"
            ),
        }
    ],
)

print("=== Claude Code Analysis ===")
print(response.content[0].text)
print(f"\nTokens used: {response.usage.input_tokens} in / {response.usage.output_tokens} out")

Run command: push these two files to a branch, open a PR against main, and wait a few seconds. The workflow triggers automatically. In the repository's "Actions" tab you'll see the run; its logs show Claude Code's analysis.

Expected output (example):

=== Claude Code Analysis ===
This PR modifies `src/payment_service.py`, adding a new function
`apply_discount(amount, percentage)`.

Possible problems:
1. The function doesn't validate that `percentage` is between 0 and 100. If you pass
   `percentage=150`, it returns a negative amount.
2. The operation is floating point; with money, consider using Decimal
   to avoid rounding errors.

Suggestions:
- Add validation for `percentage` with an appropriate raise.
- Switch to `Decimal` for the monetary calculations.

Tokens used: 850 in / 245 out

Dissecting the YAML Piece by Piece

Every part of the workflow has a specific purpose. Let's go line by line.

Triggers: when it fires

on:
  pull_request:
    types: [opened, synchronize]
  • pull_request activates the workflow on PR events
  • types: [opened, synchronize] limits it to two events:
    • opened — the PR was opened
    • synchronize — a push was made to the PR's branch (new changes)

Without types, the workflow would also run on closed, reviewed, labeled, etc. — generating runs you don't need. Restricting is saving costs.

Job and machine

jobs:
  analyze:
    runs-on: ubuntu-latest
  • analyze is the internal name of the job (used for references)
  • runs-on: ubuntu-latest is the virtual machine where it runs. For Claude Code in CI, Ubuntu is the fastest and cheapest option.

Essential steps

Step 1 — Checkout the code

- name: Checkout code
  uses: actions/checkout@v4
  with:
    fetch-depth: 0

actions/checkout@v4 is the official action that clones the repo onto the runner's machine. Critical: fetch-depth: 0 clones the full history. Without this, the git diff in the next step can't compare against main because the runner only has one commit.

Step 2 — Set up Python

- name: Setup Python
  uses: actions/setup-python@v5
  with:
    python-version: '3.11'

Installs Python on the runner. Version 3.11 is stable and supported by the Anthropic SDK.

Step 3 — Install the SDK

- name: Install Claude Code SDK
  run: pip install anthropic

Installs the official package. For production, you'll want to pin the version: pip install anthropic==0.39.0 (or the latest stable).

Step 4 — Extract the diff

- name: Get PR diff
  id: diff
  run: |
    git diff origin/${{ github.base_ref }}...HEAD > pr_diff.txt
    echo "diff_size=$(wc -l < pr_diff.txt)" >> $GITHUB_OUTPUT
  • github.base_ref is the PR's target branch (typically main)
  • origin/main...HEAD is the diff range
  • The output is saved to pr_diff.txt so the next step can use it
  • echo ... >> $GITHUB_OUTPUT exports the variable diff_size for optional use in later steps

Step 5 — Run Claude Code

- name: Run Claude Code analysis
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
  run: python .github/scripts/analyze_pr.py
  • ${{ secrets.ANTHROPIC_API_KEY }} references a secret configured in the repo (capsule 03 develops this)
  • env passes it as an environment variable to the Python script
  • The script reads the diff and produces the analysis

Working the Workflow Locally Before the Push

A good practice: test the Python script locally before pushing it to CI.

# On your machine, at the root of the repo
export ANTHROPIC_API_KEY="sk-ant-..."

# Generate a test diff
git diff main...HEAD > pr_diff.txt

# Run the script locally
python .github/scripts/analyze_pr.py

If the script works locally, it'll also work in GitHub Actions (the environment is predictable). This practice saves you the "push → watch it fail → fix → push again" cycle.


Common Pitfalls in the First Workflow

Five errors that show up specifically when writing your first Claude Code workflow.

Error 1: Forgetting fetch-depth: 0

Symptom: The "Get PR diff" step fails with a message about the base commit not existing.

Why it happens: By default, actions/checkout does a shallow clone (only the last commit). Without the full history, git diff origin/main...HEAD can't resolve origin/main.

How to fix it: Always include fetch-depth: 0 when you need to compare against another branch.

Error 2: Invalid YAML syntax

Symptom: GitHub shows "invalid workflow file" before running.

Why it happens: YAML is sensitive to indentation. Mixing tabs and spaces, indenting a step wrong, or putting a colon with no space breaks the parser.

How to fix it: Use an editor with YAML linting (VS Code with the YAML extension does it natively). Before pushing, validate the file with yamllint or GitHub Actions' built-in linter.

Error 3: Hardcoding the API key "to test"

Symptom: The workflow works, but ANTHROPIC_API_KEY: sk-ant-real... stays in the git history.

Why it happens: It's the classic temptation while debugging. "I'll put it here so it works, then move it to Secrets." But any commit with the key stays public forever (even if you rotate it later).

How to fix it: Set up GitHub Secrets from the first commit (capsule 03). If you accidentally commit a key, revoke it immediately from the Anthropic console and rotate.

Error 4: Triggers too broad

Symptom: The workflow runs on every push of every branch, generating dozens of runs a day and unexpected costs.

Why it happens: Setting on: push with no filters, or on: [push, pull_request] without restricting types.

How to fix it: Start with specific triggers (pull_request: types: [opened, synchronize]) and add more only if there's a real need. Capsule 05 develops selectivity strategies.

Error 5: The Python script fails and the workflow shows "success"

Symptom: The workflow is marked as successful even though the script failed silently.

Why it happens: The script may catch the exception and not propagate the error. If pr_diff.txt is empty or the SDK fails, the script exits with exit code 0 even though it produced no analysis.

How to fix it: Validate exit codes in the script. Use exit(1) on errors. In the workflow step, avoid continue-on-error: true except when it's intentional.


Diagnosis: Verify Your First Workflow

Question 1: Is your YAML file in `.github/workflows/`?

GitHub only detects workflows in that folder. If you put it somewhere else, it won't trigger.

Question 2: Did you configure the `ANTHROPIC_API_KEY` secret in the repo?

Settings → Secrets and variables → Actions → New repository secret. If it's not configured, the workflow runs but the script fails with "API key missing". Capsule 03 develops this.

Question 3: Did you test the Python script locally before pushing?

If it works locally, it almost certainly works in CI. If you never tested it locally, you'll iterate through several push-fail-push cycles before it works.

Question 4: Did the workflow run when you opened the PR?

If it doesn't show up in "Actions", check: (1) that the file is on the PR's target branch, (2) that the YAML syntax is valid, (3) that the on: triggers include pull_request.

Question 5: Do the workflow logs show Claude Code's analysis?

If it says "No changes to analyze", the diff came out empty (probably fetch-depth wrong). If it shows an API error, check the secret. If it shows the analysis: your first workflow works.


Exercises

Exercise 1: Set up the first workflow (Easy)

Create the two files (workflow + Python script) in a test repository. Configure the ANTHROPIC_API_KEY secret. Open a PR with a simple change. Verify that the analysis shows up in the logs.

See verification checklist
  • .github/workflows/claude-code-analysis.yml created on the target branch
  • .github/scripts/analyze_pr.py created
  • ANTHROPIC_API_KEY secret configured in Settings
  • PR opened against the branch that has the files
  • Workflow run shows up in the "Actions" tab
  • Logs show the analysis with text from the model

Exercise 2: Change the trigger (Medium)

Modify the workflow so it also runs when the needs-review label is added to the PR (not only on opened/synchronize). Verify it works by adding the label manually.

See solution
on:
  pull_request:
    types: [opened, synchronize, labeled]

jobs:
  analyze:
    runs-on: ubuntu-latest
    if: github.event.action != 'labeled' || github.event.label.name == 'needs-review'
    steps:
      # ... rest the same

The if filters: if the action is labeled, it only continues when the label is needs-review. For opened and synchronize, the workflow always runs.

Exercise 3: Robust error handling (Medium)

Modify the Python script so it:

  1. Fails with exit code 1 if the diff is empty (instead of exit 0).
  2. Catches API errors and shows them clearly before ending with exit 1.
See solution
import os, sys
from pathlib import Path
from anthropic import Anthropic, APIError

try:
    diff_text = Path("pr_diff.txt").read_text()
except FileNotFoundError:
    print("ERROR: pr_diff.txt not found.", file=sys.stderr)
    sys.exit(1)

if not diff_text.strip():
    print("ERROR: the diff is empty. Check fetch-depth.", file=sys.stderr)
    sys.exit(1)

try:
    client = Anthropic()
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=2000,
        messages=[{"role": "user", "content": f"Analyze this diff:\n{diff_text}"}],
    )
    print(response.content[0].text)
except APIError as e:
    print(f"API ERROR: {e}", file=sys.stderr)
    sys.exit(1)

Summary

  • The YAML workflow lives in .github/workflows/ and triggers according to the on: you configure
  • Specific triggers (not on: push with no filter) save costs
  • fetch-depth: 0 is necessary to compare against another branch
  • The headless SDK is invoked from an external Python script, not inline in the YAML
  • Testing locally before pushing saves iteration cycles
  • Validating exit codes prevents the workflow from showing "success" when the script failed silently

Next capsule: 03 — Secrets management with GitHub Secrets. The part we don't negotiate: how to handle your API key without exposing it, at what level to configure the secret (repo / org / environment), and what to do if you accidentally commit it.


Additional Resources

  1. GitHub Actions Workflow Syntax — Complete official reference
  2. actions/checkout — Official checkout action
  3. actions/setup-python — Official Python setup action
  4. Anthropic Python SDK — Official SDK
  5. GitHub Actions Pricing — Runner minute costs
  6. yamllint — YAML linter to validate before pushing