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-diffruns first (fast, output used by all)- The 3 stages run in parallel (independent of each other)
pr-review-summaryconsolidates at the end, even if some failconcurrencycancels 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=githubproduces native annotations (inline warnings on the PR)mypywithcontinue-on-error: truefor a warning without a block--cov-fail-under=70requires 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:
- An
extract-diffjob - 3 parallel jobs (code-review, security-scan, tests)
- A
summaryjob 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:
- Pip cache
- Concurrency with cancel
- Path filter
- Skip drafts
- 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-diffjob 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
- GitHub Actions: jobs in parallel — Parallelization syntax
- GitHub Actions: caching — Cache strategies
- actions/upload-artifact — Pass files between jobs
- ruff — Ultra-fast linter
- Codecov — Coverage tracking
- GitHub Actions: workflow status — Access the status of previous jobs