Module 6: Project — Complete CI/CD Pipeline with Claude Code

Implementation: Stages 1-3 (PR Review)

Implementation: Stages 1-3 (PR Review)

Overview

This capsule is where you start building the pipeline. You take the design from capsule 02 and implement Phase 1: the 3 PR review stages that run in parallel on every pull request — automatic code review, security scan, and tests + linting.

This is the most visible phase of the pipeline for the team: every developer will see it on every PR. It's worth doing well — reusable, fast, with useful feedback. You'll integrate the techniques from Modules 1, 2, and 5 into a single coherent workflow, avoiding duplication and leveraging parallelism.

By the end, you'll have the pipeline's first phase working end-to-end in a test repository — a solid foundation for phases 2-4.


The Workflow Structure

# .github/workflows/pr-review.yml
name: PR Review (Phase 1)

on:
  pull_request:
    types: [opened, synchronize, ready_for_review]
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'requirements.txt'

permissions:
  contents: read
  pull-requests: write
  security-events: write

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  # Common job: extract the diff (output used by the others)
  extract-diff:
    runs-on: ubuntu-latest
    if: github.event.pull_request.draft == false
    outputs:
      diff_size: ${{ steps.extract.outputs.diff_size }}
      should_review: ${{ steps.extract.outputs.should_review }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      
      - name: Extract filtered diff
        id: extract
        run: |
          git diff origin/${{ github.base_ref }}...HEAD \
            --diff-filter=ACMR \
            -- '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.go' '*.rb' \
            > pr_diff.txt
          
          SIZE=$(wc -l < pr_diff.txt)
          echo "diff_size=$SIZE" >> $GITHUB_OUTPUT
          
          if [ "$SIZE" -eq 0 ]; then
            echo "should_review=false" >> $GITHUB_OUTPUT
          elif [ "$SIZE" -gt 2000 ]; then
            echo "should_review=large" >> $GITHUB_OUTPUT
          else
            echo "should_review=true" >> $GITHUB_OUTPUT
          fi
      
      - uses: actions/upload-artifact@v4
        with:
          name: pr-diff
          path: pr_diff.txt
          retention-days: 1

  # ========================================
  # Parallel stages
  # ========================================
  
  code-review:
    needs: extract-diff
    if: needs.extract-diff.outputs.should_review == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install anthropic requests
      - uses: actions/download-artifact@v4
        with: { name: pr-diff }
      
      - name: Run code review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          CLAUDE_MODEL: 'claude-haiku-4-5'
        run: python scripts/code_review.py

  security-scan:
    needs: extract-diff
    if: needs.extract-diff.outputs.should_review == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install anthropic requests
      - uses: actions/download-artifact@v4
        with: { name: pr-diff }
      
      - name: Run security scan
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CLAUDE_MODEL: 'claude-sonnet-5'
        run: python scripts/security_scan.py

  tests-and-linting:
    needs: extract-diff
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      
      - run: pip install -r requirements.txt
      
      - name: Run linting
        run: |
          pip install ruff
          ruff check .
      
      - name: Run tests
        run: pytest --cov=src --cov-report=xml
      
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  # Rollup job that reports the consolidated status
  pr-review-summary:
    needs: [code-review, security-scan, tests-and-linting]
    if: always()  # runs even if previous jobs fail
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install requests
      
      - name: Post consolidated summary
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CODE_REVIEW_STATUS: ${{ needs.code-review.result }}
          SECURITY_SCAN_STATUS: ${{ needs.security-scan.result }}
          TESTS_STATUS: ${{ needs.tests-and-linting.result }}
        run: python scripts/post_consolidated_summary.py

What matters about the structure:

  • extract-diff runs first (fast, output used by all)
  • The 3 stages run in parallel (independent of each other)
  • pr-review-summary consolidates at the end, even if some fail
  • concurrency cancels old runs of the same PR (capsule 05 M1)

The Diff Extraction Job

"""scripts/extract_pr_files.py — used by the extract-diff job"""
# If the inline bash isn't enough, you can move it to a Python script:

import json
import os
import subprocess
import sys


RELEVANT_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}


def main() -> int:
    base_ref = os.environ["GITHUB_BASE_REF"]
    
    # Make sure the remote is up to date
    subprocess.run(["git", "fetch", "origin", base_ref], check=True)
    
    # Generate the filtered diff
    result = subprocess.run(
        ["git", "diff", f"origin/{base_ref}...HEAD",
         "--diff-filter=ACMR",
         "--", "*.py", "*.ts", "*.tsx", "*.js", "*.jsx", "*.go", "*.rb"],
        capture_output=True, text=True, check=True,
    )
    
    diff = result.stdout
    
    # Save
    with open("pr_diff.txt", "w") as f:
        f.write(diff)
    
    # Stats
    diff_lines = len(diff.splitlines())
    
    # Decide whether it's worth reviewing
    if diff_lines == 0:
        should_review = "false"
    elif diff_lines > 2000:
        should_review = "large"  # signal for more aggressive chunking
    else:
        should_review = "true"
    
    # Output for the next step
    with open(os.environ.get("GITHUB_OUTPUT", "/dev/stdout"), "a") as f:
        f.write(f"diff_size={diff_lines}\n")
        f.write(f"should_review={should_review}\n")
    
    print(f"Diff size: {diff_lines} lines")
    print(f"Should review: {should_review}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Code Review Job (Reusing Module 2)

You already built the script in Module 2 capsule 03. Here you reuse it as is:

# scripts/code_review.py
# Script from Module 2 capsule 03 — unchanged.
# Takes pr_diff.txt as input, posts inline comments to the PR.

Key point: don't rewrite the Module 2 code. Reuse it. If you implemented it correctly in M2 capsule 03, this job is plug-and-play.


Security Scan Job (Reusing Module 5)

Similar to the code review, the script comes from Module 5 capsule 02:

# scripts/security_scan.py
# Script from Module 5 capsule 02 — unchanged.
# Detects vulnerabilities, posts findings to the PR.

Key differences between code review and security scan:

CODE REVIEW (M2):
- Model: Haiku (more economical)
- Operation: suggest-only by default
- Output: PR comment + inline comments
- Failure behavior: warn (doesn't block)

SECURITY SCAN (M5):
- Model: Sonnet (more quality)
- Operation: blocks on critical findings
- Output: structured PR comment
- Failure behavior: block on critical

Each with its own prompt, model, and policy — but in the same parallel workflow.


Tests and Linting Job

tests-and-linting:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: '3.11'
        cache: 'pip'
    
    - run: pip install -r requirements.txt
    
    - name: Linting (ruff)
      run: |
        pip install ruff
        ruff check . --output-format=github
    
    - name: Type checking (mypy, optional)
      continue-on-error: true  # warning, no block
      run: |
        pip install mypy
        mypy src/
    
    - name: Unit tests
      run: pytest tests/ --cov=src --cov-report=xml --cov-fail-under=70
    
    - name: Upload coverage
      if: always()
      uses: codecov/codecov-action@v4
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: ./coverage.xml

Notes:

  • ruff check . --output-format=github produces native annotations (inline warnings on the PR)
  • mypy with continue-on-error: true for a warning without a block
  • --cov-fail-under=70 requires a minimum coverage of 70%

The Consolidated Summary

The workflow's final job is a status rollup — it takes the results of the 3 parallel jobs and posts a clear summary to the PR:

"""scripts/post_consolidated_summary.py

Posts a unified comment to the PR summarizing the 3 jobs.
"""
import os
import sys
import requests


MARKER = "<!-- pr-review-phase-1-summary -->"


def status_emoji(status: str) -> str:
    return {
        "success": "✅",
        "failure": "❌",
        "cancelled": "🚫",
        "skipped": "⏭️",
    }.get(status, "❓")


def status_label(status: str) -> str:
    return {
        "success": "PASS",
        "failure": "FAIL",
        "cancelled": "CANCELLED",
        "skipped": "SKIPPED",
    }.get(status, "UNKNOWN")


def build_summary() -> str:
    code_review = os.environ.get("CODE_REVIEW_STATUS", "skipped")
    security = os.environ.get("SECURITY_SCAN_STATUS", "skipped")
    tests = os.environ.get("TESTS_STATUS", "skipped")
    
    overall_pass = all(s == "success" for s in [code_review, security, tests])
    
    return f"""{MARKER}

# 🤖 PR Review Summary (Phase 1)

| Check | Status |
|-------|--------|
| Code Review | {status_emoji(code_review)} {status_label(code_review)} |
| Security Scan | {status_emoji(security)} {status_label(security)} |
| Tests + Linting | {status_emoji(tests)} {status_label(tests)} |

{'✅ **All checks passed — PR ready for human review.**' if overall_pass
else '⚠️  **There are checks that require attention before the merge.**'}

---

### Details
- **Code review:** see the inline comments above (with the `claude-code-bot` marker)
- **Security scan:** see the comment with the `security-scan-bot` marker
- **Tests + linting:** see the detail in the [workflow run]({get_run_url()})

*Summary generated automatically.*
"""


def get_run_url() -> str:
    repo = os.environ.get("GITHUB_REPOSITORY", "")
    run_id = os.environ.get("GITHUB_RUN_ID", "")
    return f"https://github.com/{repo}/actions/runs/{run_id}"


def find_existing_summary(comments: list) -> dict | None:
    for c in comments:
        if MARKER in c.get("body", ""):
            return c
    return None


def main() -> int:
    repo = os.environ["GITHUB_REPOSITORY"]
    pr_number = os.environ["PR_NUMBER"]
    token = os.environ["GITHUB_TOKEN"]
    
    body = build_summary()
    
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }
    
    list_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
    existing = requests.get(list_url, headers=headers).json()
    summary_comment = find_existing_summary(existing)
    
    if summary_comment:
        # Update
        update_url = f"https://api.github.com/repos/{repo}/issues/comments/{summary_comment['id']}"
        r = requests.patch(update_url, headers=headers, json={"body": body})
    else:
        # Create
        r = requests.post(list_url, headers=headers, json={"body": body})
    
    if r.status_code in [200, 201]:
        print("Consolidated summary posted")
        return 0
    
    print(f"ERROR: {r.status_code} {r.text}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Result in the PR: a single comment at the top of the PR with the summary of the 3 jobs. Each job can have its own detailed comment, but the summary is the panoramic view.


Important Optimizations

1. Pip cache

Each job installs dependencies. Without a cache, that's ~30s per job × 3 = ~90s wasted.

- uses: actions/setup-python@v5
  with:
    python-version: '3.11'
    cache: 'pip'  # ← automatic cache

Reduces to ~5s per job.

2. Concurrency: cancel old runs

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

If the developer pushes 3 times in a row to the PR, only the last one completes the 3 jobs. The previous ones are canceled.

3. Path filtering

The workflow only triggers if the PR touches source code (not docs, configs):

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'requirements.txt'

Changes only to docs don't need automatic code review.

4. Skip draft PRs

extract-diff:
  if: github.event.pull_request.draft == false

Saves tokens on work in progress.

5. Skip PRs without a relevant diff

The extract-diff job already outputs should_review. The other jobs respect it:

code-review:
  if: needs.extract-diff.outputs.should_review == 'true'

If the PR only changes non-relevant files, code review skips.


Common Pitfalls

1. Stages depending on the same job unnecessarily

Symptom: Code review waits for the security scan even though they're independent.

Why it happens: Configuring needs: out of habit.

How to fix it: needs: only when there's a real dependency (a stage's output used by another). Code review and security scan both depend on extract-diff, but not on each other. They can run in parallel.

2. Diff extracted 3 times (one per stage)

Symptom: Each stage does its own git diff. Repeating the same thing 3 times.

Why it happens: Each job is independent and nobody centralized the extraction.

How to fix it: An extract-diff job that runs first and publishes as an artifact. The other stages download it.

3. Duplicate or conflicting comments

Symptom: A PR with 5 bot comments — one per job and two from the summary.

Why it happens: Each stage posts its comment, the summary does too, without coordination.

How to fix it: Unique markers per comment type (<!-- code-review-bot -->, <!-- security-scan-bot -->, <!-- pr-review-summary -->). Each stage updates its comment instead of creating a new one.

4. Workflow takes 10+ minutes

Symptom: A Phase 1 that should be ~5 min takes 12.

Why it happens: No parallelization, no cache, no filtering.

How to fix it: Apply the 5 optimizations above. Phase 1 should be ~5 min with parallelization + cache.

5. if: always() on the summary that hides failures

Symptom: The summary shows "everything OK" but a job failed.

Why it happens: The summary has if: always() to run even with fails, but the body doesn't correctly reflect the statuses.

How to fix it: The summary receives ${{ needs.X.result }} and builds the comment based on those statuses. The build_summary() function above does it correctly.


Diagnosis

Question 1: Does your workflow have the 3 stages running in parallel?

If they're sequential, the pipeline takes 3x longer. Parallelize with needs: extract-diff but independent of each other.

Question 2: Does each stage have its own comment with a unique marker?

Markers let you update instead of duplicating. Without markers, after 5 pushes there are 15 bot comments.

Question 3: Does your workflow respect the path filter and skip drafts?

Without these filters, you waste tokens on things that don't need review (docs, work-in-progress).

Question 4: Do you have a consolidated summary job?

Without a summary, the team has to look in 3 places. With a summary, everything in one comment.

Question 5: Did you configure `concurrency` to cancel old runs?

Without this, 5 pushes in a row = 5 parallel runs = 5x the costs.


Exercises

Exercise 1: Basic workflow (Medium)

Implement the workflow with:

  1. An extract-diff job
  2. 3 parallel jobs (code-review, security-scan, tests)
  3. A summary job with consolidation

Test it on a test PR. Verify:

  • The 3 jobs run in parallel
  • The summary correctly reflects the statuses
  • It takes less than the sum of the 3 individually

Exercise 2: Optimizations (Easy)

Add the 5 optimizations:

  1. Pip cache
  2. Concurrency with cancel
  3. Path filter
  4. Skip drafts
  5. Skip PRs without a relevant diff

Measure the time before and after.

Exercise 3: Unique markers per stage (Hard)

Modify the scripts (code review, security scan, summary) so each one has its own marker and updates existing comments instead of creating new ones.

Test it: 3 pushes to the same PR should result in exactly 3 bot comments at the end, not 9.


Summary

  • Parallel Phase 1: code review + security scan + tests + linting run simultaneously
  • A common extract-diff job avoids duplicating work in each stage
  • A consolidated summary job gives the panoramic view
  • Unique markers (<!-- code-review-bot -->, etc.) avoid comment duplication
  • 5 key optimizations: cache, concurrency, path filter, skip drafts, skip if there's no diff
  • Reuse of scripts from Modules 2 and 5 — don't rewrite
  • Phase 1 target: ~5 min from push to visible summary

Next capsule: 04 — Implementation: Stages 4-6 (Deployment). You have Phase 1 working. Now you implement Phase 2-3: changelog, readiness, deploy to staging, approval gate, deploy to production. The part that moves code from the repo to the users' environment.


Additional Resources

  1. GitHub Actions: jobs in parallel — Parallelization syntax
  2. GitHub Actions: caching — Cache strategies
  3. actions/upload-artifact — Pass files between jobs
  4. ruff — Ultra-fast linter
  5. Codecov — Coverage tracking
  6. GitHub Actions: workflow status — Access the status of previous jobs