mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
refactor(ci): PR-based Claude review instead of batch extraction [skip-regression-check]
Restructure staging-ci to create the promotion PR first, then let claude-review.yml trigger on the PR via pull_request event. Claude posts findings as native PR review comments (no JSON extraction). - claude-review.yml: trigger on pull_request with staging-promotion label, uses claude-code-action in native PR review mode, Sonnet tags comments with [SEVERITY:CONFIDENCE] for downstream parsing - staging-ci.yml: new create-promotion-pr job runs in parallel with tests/e2e, gate waits for Claude review check on PR, parses review comments to create issues and evaluate blocking findings - create-labels.sh: add staging-promotion label Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1ecc41e0ce
commit
12508253ba
@@ -1,281 +1,49 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
diff_range:
|
||||
description: "Git diff range to review (e.g., abc123..def456)"
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: "Git ref to checkout"
|
||||
required: false
|
||||
type: string
|
||||
default: staging
|
||||
outputs:
|
||||
has_blocking:
|
||||
description: "Whether blocking findings were found (CRITICAL ≥80 confidence)"
|
||||
value: ${{ jobs.review.outputs.has_blocking }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diff_range:
|
||||
description: "Git diff range to review"
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: "Git ref to checkout"
|
||||
required: false
|
||||
type: string
|
||||
default: staging
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write # Required by anthropics/claude-code-action for OIDC
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Claude Code Review
|
||||
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_blocking: ${{ steps.gate-check.outputs.has_blocking }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check diff availability
|
||||
id: diff
|
||||
run: |
|
||||
RANGE="${{ inputs.diff_range }}"
|
||||
if [ -z "$RANGE" ]; then
|
||||
echo "diff_available=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No diff range provided"
|
||||
else
|
||||
echo "diff_available=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Reviewing range: ${RANGE}"
|
||||
fi
|
||||
|
||||
- name: Run Claude Code review
|
||||
id: claude-review
|
||||
if: steps.diff.outputs.diff_available == 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
prompt: |
|
||||
Review the diff for range `${{ inputs.diff_range }}` in this repo.
|
||||
Run `git diff ${{ inputs.diff_range }}` to see what changed.
|
||||
Review this PR for security vulnerabilities, bugs, and code quality issues.
|
||||
|
||||
Categorize each finding by severity (CRITICAL/HIGH/MEDIUM/LOW) and assign
|
||||
a confidence score (0-100). Only report issues you're confident about.
|
||||
Prefix EVERY review comment with a severity and confidence tag:
|
||||
[SEVERITY:CONFIDENCE] where SEVERITY is CRITICAL/HIGH/MEDIUM/LOW
|
||||
and CONFIDENCE is 0-100.
|
||||
|
||||
CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
LOW: documentation gaps, naming suggestions
|
||||
Example: [CRITICAL:92] This .unwrap() can panic in production when the config file is missing.
|
||||
|
||||
Confidence: 90-100 = certain, 70-89 = very likely, 50-69 = possible, 0-49 = speculative
|
||||
Severity guide:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
|
||||
Return structured JSON with: summary, critical[], high[], medium[], low[]
|
||||
Each finding: {title, description, file, line, confidence}
|
||||
Confidence guide:
|
||||
- 90-100: certain this is a real issue
|
||||
- 70-89: very likely but needs human verification
|
||||
- 50-69: possible issue, not fully sure of context
|
||||
- 0-49: speculative, might be false positive
|
||||
|
||||
Only report real issues you're confident about. Be concise. No nitpicks.
|
||||
claude_args: "--max-turns 5"
|
||||
|
||||
- name: Extract results from execution log
|
||||
id: process-results
|
||||
if: always() && steps.diff.outputs.diff_available == 'true'
|
||||
run: |
|
||||
FALLBACK='{"summary":"Claude review did not produce structured output.","critical":[],"high":[],"medium":[],"low":[]}'
|
||||
EXEC_FILE="${{ steps.claude-review.outputs.execution_file }}"
|
||||
|
||||
if [ -n "$EXEC_FILE" ] && [ -f "$EXEC_FILE" ]; then
|
||||
# Extract the last assistant text message as the review result
|
||||
RESULT=$(jq -r '[.[] | select(.type == "assistant" and .message != null) | .message] | last // empty' "$EXEC_FILE" 2>/dev/null || true)
|
||||
|
||||
# Try to parse as JSON (Claude may wrap in markdown code block)
|
||||
if [ -n "$RESULT" ]; then
|
||||
# Strip markdown code fences if present
|
||||
CLEANED=$(echo "$RESULT" | sed 's/^```json//;s/^```//;s/```$//' | jq '.' 2>/dev/null || true)
|
||||
if [ -n "$CLEANED" ]; then
|
||||
echo "$CLEANED" > /tmp/review-results.json
|
||||
else
|
||||
echo "::warning::Could not parse Claude output as JSON, using fallback"
|
||||
echo "$FALLBACK" > /tmp/review-results.json
|
||||
fi
|
||||
else
|
||||
echo "::warning::No assistant messages in execution log, using fallback"
|
||||
echo "$FALLBACK" > /tmp/review-results.json
|
||||
fi
|
||||
else
|
||||
echo "::warning::No execution file found, using fallback"
|
||||
echo "$FALLBACK" > /tmp/review-results.json
|
||||
fi
|
||||
|
||||
# Ensure confidence field exists on all findings (default to 50 if missing)
|
||||
for severity in critical high medium low; do
|
||||
jq ".$severity = [.$severity[]? | .confidence = (.confidence // 50)]" /tmp/review-results.json > /tmp/review-results-tmp.json
|
||||
mv /tmp/review-results-tmp.json /tmp/review-results.json
|
||||
done
|
||||
|
||||
- name: Create GitHub issues for findings
|
||||
if: always() && steps.diff.outputs.diff_available == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RANGE="${{ inputs.diff_range }}"
|
||||
REPO="${{ github.repository }}"
|
||||
# Use current HEAD SHA for permalinks
|
||||
SHA=$(git rev-parse HEAD)
|
||||
ISSUES_CREATED=0
|
||||
|
||||
# Function to create an issue for a finding
|
||||
create_issue() {
|
||||
local severity="$1" title="$2" file="$3" line="$4" confidence="$5" labels="$6"
|
||||
|
||||
jq -r ".$severity[$i].description // \"No description\"" /tmp/review-results.json > /tmp/issue-desc.txt
|
||||
{
|
||||
echo "## ${severity^^} Issue Found by Staging CI Review"
|
||||
echo ""
|
||||
echo "**Severity:** ${severity^^}"
|
||||
echo "**Confidence:** ${confidence}/100"
|
||||
echo "**File:** [\`${file}:${line}\`](https://github.com/${REPO}/blob/${SHA}/${file}#L${line})"
|
||||
echo "**Commit range:** \`${RANGE}\`"
|
||||
echo ""
|
||||
echo "### Description"
|
||||
cat /tmp/issue-desc.txt
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Auto-created by staging-ci Claude Code review*"
|
||||
} > /tmp/issue-body.md
|
||||
|
||||
gh issue create \
|
||||
--title "[${severity^^}] ${title}" \
|
||||
--body-file /tmp/issue-body.md \
|
||||
--label "${labels}"
|
||||
ISSUES_CREATED=$((ISSUES_CREATED + 1))
|
||||
}
|
||||
|
||||
# CRITICAL: always create issue (any confidence)
|
||||
# ≥80 → bug,risk: high,staging-ci-review
|
||||
# 50-79 → bug,risk: high,staging-ci-review
|
||||
# <50 → bug,risk: high,staging-ci-review,low-confidence
|
||||
CRIT_COUNT=$(jq '.critical | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
for i in $(seq 0 $((CRIT_COUNT > 0 ? CRIT_COUNT - 1 : -1))); do
|
||||
[ "$i" -lt 0 ] && break
|
||||
TITLE=$(jq -r ".critical[$i].title // \"Untitled\"" /tmp/review-results.json)
|
||||
FILE=$(jq -r ".critical[$i].file // \"unknown\"" /tmp/review-results.json)
|
||||
LINE=$(jq -r ".critical[$i].line // 0" /tmp/review-results.json)
|
||||
CONF=$(jq -r ".critical[$i].confidence // 50" /tmp/review-results.json)
|
||||
|
||||
LABELS="bug,risk: high,staging-ci-review"
|
||||
if [ "$CONF" -lt 50 ]; then
|
||||
LABELS="${LABELS},low-confidence"
|
||||
fi
|
||||
create_issue "critical" "$TITLE" "$FILE" "$LINE" "$CONF" "$LABELS"
|
||||
done
|
||||
|
||||
# HIGH: create issue if confidence ≥50
|
||||
HIGH_COUNT=$(jq '.high | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
for i in $(seq 0 $((HIGH_COUNT > 0 ? HIGH_COUNT - 1 : -1))); do
|
||||
[ "$i" -lt 0 ] && break
|
||||
CONF=$(jq -r ".high[$i].confidence // 50" /tmp/review-results.json)
|
||||
[ "$CONF" -lt 50 ] && continue
|
||||
TITLE=$(jq -r ".high[$i].title // \"Untitled\"" /tmp/review-results.json)
|
||||
FILE=$(jq -r ".high[$i].file // \"unknown\"" /tmp/review-results.json)
|
||||
LINE=$(jq -r ".high[$i].line // 0" /tmp/review-results.json)
|
||||
create_issue "high" "$TITLE" "$FILE" "$LINE" "$CONF" "bug,risk: medium,staging-ci-review"
|
||||
done
|
||||
|
||||
# MEDIUM: create issue if confidence ≥80
|
||||
MED_COUNT=$(jq '.medium | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
for i in $(seq 0 $((MED_COUNT > 0 ? MED_COUNT - 1 : -1))); do
|
||||
[ "$i" -lt 0 ] && break
|
||||
CONF=$(jq -r ".medium[$i].confidence // 50" /tmp/review-results.json)
|
||||
[ "$CONF" -lt 80 ] && continue
|
||||
TITLE=$(jq -r ".medium[$i].title // \"Untitled\"" /tmp/review-results.json)
|
||||
FILE=$(jq -r ".medium[$i].file // \"unknown\"" /tmp/review-results.json)
|
||||
LINE=$(jq -r ".medium[$i].line // 0" /tmp/review-results.json)
|
||||
create_issue "medium" "$TITLE" "$FILE" "$LINE" "$CONF" "risk: medium,staging-ci-review"
|
||||
done
|
||||
|
||||
# LOW: create issue if confidence ≥80
|
||||
LOW_COUNT=$(jq '.low | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
for i in $(seq 0 $((LOW_COUNT > 0 ? LOW_COUNT - 1 : -1))); do
|
||||
[ "$i" -lt 0 ] && break
|
||||
CONF=$(jq -r ".low[$i].confidence // 50" /tmp/review-results.json)
|
||||
[ "$CONF" -lt 80 ] && continue
|
||||
TITLE=$(jq -r ".low[$i].title // \"Untitled\"" /tmp/review-results.json)
|
||||
FILE=$(jq -r ".low[$i].file // \"unknown\"" /tmp/review-results.json)
|
||||
LINE=$(jq -r ".low[$i].line // 0" /tmp/review-results.json)
|
||||
create_issue "low" "$TITLE" "$FILE" "$LINE" "$CONF" "risk: low,staging-ci-review"
|
||||
done
|
||||
|
||||
echo "Created ${ISSUES_CREATED} issues total"
|
||||
|
||||
- name: Write review summary
|
||||
if: always() && steps.diff.outputs.diff_available == 'true'
|
||||
run: |
|
||||
if [ ! -f /tmp/review-results.json ]; then
|
||||
echo "No review results to summarize" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "## Claude Code Review Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
SUMMARY=$(jq -r '.summary // "No summary"' /tmp/review-results.json)
|
||||
echo "$SUMMARY" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Count findings by severity
|
||||
CRIT=$(jq '.critical | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
HIGH=$(jq '.high | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
MED=$(jq '.medium | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
LOW=$(jq '.low | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
|
||||
# Count blocking (CRITICAL ≥80)
|
||||
BLOCKING=$(jq '[.critical[]? | select(.confidence >= 80)] | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
|
||||
echo "| Severity | Count | Blocking (≥80 conf) |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|----------|-------|---------------------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Critical | $CRIT | $BLOCKING |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| High | $HIGH | — |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Medium | $MED | — |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Low | $LOW | — |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Print all findings with confidence scores and computed action
|
||||
TOTAL=$((CRIT + HIGH + MED + LOW))
|
||||
if [ "$TOTAL" -gt 0 ]; then
|
||||
echo "### All Findings" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Severity | Confidence | Action | Title | File |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|----------|-----------|--------|-------|------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# CRITICAL: always issue, ≥80 blocks
|
||||
jq -r '.critical[]? | "| CRITICAL | \(.confidence) | \(if .confidence >= 80 then "BLOCKS + issue" elif .confidence >= 50 then "issue" else "issue (low-confidence)" end) | \(.title) | \(.file):\(.line) |"' /tmp/review-results.json >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
# HIGH: issue if ≥50
|
||||
jq -r '.high[]? | "| HIGH | \(.confidence) | \(if .confidence >= 50 then "issue" else "summary only" end) | \(.title) | \(.file):\(.line) |"' /tmp/review-results.json >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
# MEDIUM: issue if ≥80
|
||||
jq -r '.medium[]? | "| MEDIUM | \(.confidence) | \(if .confidence >= 80 then "issue" else "summary only" end) | \(.title) | \(.file):\(.line) |"' /tmp/review-results.json >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
# LOW: issue if ≥80
|
||||
jq -r '.low[]? | "| LOW | \(.confidence) | \(if .confidence >= 80 then "issue" else "summary only" end) | \(.title) | \(.file):\(.line) |"' /tmp/review-results.json >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Set gate output
|
||||
id: gate-check
|
||||
if: always()
|
||||
run: |
|
||||
# Default: no blocking findings
|
||||
HAS_BLOCKING=false
|
||||
|
||||
if [ -f /tmp/review-results.json ]; then
|
||||
# Only CRITICAL findings with confidence ≥80 block
|
||||
BLOCKING=$(jq '[.critical[]? | select(.confidence >= 80)] | length' /tmp/review-results.json 2>/dev/null || echo 0)
|
||||
if [ "$BLOCKING" -gt 0 ]; then
|
||||
HAS_BLOCKING=true
|
||||
echo "::warning::Found ${BLOCKING} blocking finding(s) (CRITICAL ≥80 confidence)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "has_blocking=${HAS_BLOCKING}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+252
-102
@@ -10,28 +10,26 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
skip_claude_gate:
|
||||
description: "Skip Claude review gate (bypass CRITICAL block)"
|
||||
description: "Skip Claude review gate (bypass blocking findings)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write # Claude review creates issues for critical findings
|
||||
pull-requests: write # Auto-promote creates + merges PR to main
|
||||
id-token: write # Required by claude-code-action for OIDC auth
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: staging-ci
|
||||
cancel-in-progress: false # Let running suites finish
|
||||
|
||||
jobs:
|
||||
# ── Gate: check for new commits ──────────────────────────────────
|
||||
# ── Check for new commits ──────────────────────────────────────
|
||||
check-changes:
|
||||
name: Check for new commits
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_changes: ${{ steps.check.outputs.has_changes }}
|
||||
last_tested: ${{ steps.check.outputs.last_tested }}
|
||||
current_head: ${{ steps.check.outputs.current_head }}
|
||||
diff_range: ${{ steps.check.outputs.diff_range }}
|
||||
steps:
|
||||
@@ -51,7 +49,6 @@ jobs:
|
||||
else
|
||||
LAST_TESTED=""
|
||||
fi
|
||||
echo "last_tested=${LAST_TESTED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
DIFF_RANGE=""
|
||||
if [ -n "$LAST_TESTED" ] && [ "$LAST_TESTED" = "$CURRENT_HEAD" ]; then
|
||||
@@ -64,7 +61,6 @@ jobs:
|
||||
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
||||
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
|
||||
else
|
||||
# First run: use merge-base with main to capture all staging changes
|
||||
git fetch origin main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
|
||||
@@ -76,7 +72,6 @@ jobs:
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "Force run requested"
|
||||
HAS_CHANGES=true
|
||||
# Use a valid empty range if no new commits
|
||||
if [ -z "$DIFF_RANGE" ]; then
|
||||
DIFF_RANGE="${CURRENT_HEAD}..${CURRENT_HEAD}"
|
||||
fi
|
||||
@@ -99,82 +94,14 @@ jobs:
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
# ── Thorough Claude Code review (reusable workflow) ──────────────
|
||||
claude-review:
|
||||
name: Claude Code Review
|
||||
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
|
||||
create-promotion-pr:
|
||||
name: Create Promotion PR
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/claude-review.yml
|
||||
with:
|
||||
diff_range: ${{ needs.check-changes.outputs.diff_range }}
|
||||
ref: staging
|
||||
secrets: inherit
|
||||
|
||||
# ── Gate: block on high-confidence findings unless overridden ────
|
||||
claude-gate:
|
||||
name: Claude Review Gate
|
||||
needs: [check-changes, tests, e2e, claude-review]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.tests.result == 'success' &&
|
||||
needs.e2e.result == 'success' &&
|
||||
needs.claude-review.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gate_passed: ${{ steps.gate.outputs.passed }}
|
||||
steps:
|
||||
- name: Evaluate gate
|
||||
id: gate
|
||||
run: |
|
||||
HAS_BLOCKING="${{ needs.claude-review.outputs.has_blocking }}"
|
||||
SKIP_INPUT="${{ inputs.skip_claude_gate }}"
|
||||
|
||||
if [ "$HAS_BLOCKING" = "true" ]; then
|
||||
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
|
||||
if [ "$SKIP_INPUT" = "true" ]; then
|
||||
echo "::warning::Gate overridden by skip_claude_gate workflow input"
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Blocking promotion to main due to CRITICAL findings (≥80 confidence)"
|
||||
echo "passed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No blocking findings (CRITICAL ≥80). Gate passed."
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Update tested tag on success ─────────────────────────────────
|
||||
update-tag:
|
||||
name: Update staging-tested tag
|
||||
needs: [check-changes, claude-gate]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.claude-gate.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update staging-tested tag
|
||||
run: |
|
||||
git tag -f staging-tested "${{ needs.check-changes.outputs.current_head }}"
|
||||
git push origin staging-tested --force
|
||||
echo "Updated staging-tested tag to ${{ needs.check-changes.outputs.current_head }}"
|
||||
|
||||
# ── Auto-promote: create PR from staging to main and merge ──────
|
||||
promote-to-main:
|
||||
name: Promote to Main
|
||||
needs: [check-changes, claude-gate, update-tag]
|
||||
if: >
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.claude-gate.result == 'success' &&
|
||||
needs.update-tag.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
pr_number: ${{ steps.create-pr.outputs.pr_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -202,7 +129,20 @@ jobs:
|
||||
echo "Staging is ${AHEAD} commits ahead of main."
|
||||
fi
|
||||
|
||||
- name: Create and merge PR to main
|
||||
- name: Close stale promotion PRs
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
# Close any existing staging->main PRs to avoid duplicates
|
||||
EXISTING=$(gh pr list --base main --head staging --state open --json number -q '.[].number')
|
||||
for PR in $EXISTING; do
|
||||
echo "Closing stale promotion PR #${PR}"
|
||||
gh pr close "$PR" --comment "Superseded by new staging-ci batch run"
|
||||
done
|
||||
|
||||
- name: Create promotion PR
|
||||
id: create-pr
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -210,15 +150,6 @@ jobs:
|
||||
RANGE="${{ needs.check-changes.outputs.diff_range }}"
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC")
|
||||
|
||||
# Check for existing open staging->main PR
|
||||
EXISTING=$(gh pr list --base main --head staging --state open --json number -q '.[0].number')
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Existing PR #${EXISTING} found. Merging it."
|
||||
gh pr merge "$EXISTING" --merge --auto
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create new PR
|
||||
PR_URL=$(gh pr create \
|
||||
--base main \
|
||||
--head staging \
|
||||
@@ -228,23 +159,239 @@ jobs:
|
||||
**Batch range:** \`${RANGE}\`
|
||||
**Triggered by:** Staging CI batch at ${TIMESTAMP}
|
||||
|
||||
All gates passed:
|
||||
- Tests: passed
|
||||
- Claude Code review: no CRITICAL findings (≥80 confidence)
|
||||
- E2E: passed
|
||||
Waiting for gates:
|
||||
- Tests: pending
|
||||
- E2E: pending
|
||||
- Claude Code review: pending (will post comments on this PR)
|
||||
|
||||
---
|
||||
*Auto-created by staging-ci workflow*" \
|
||||
--label "staging-ci-review")
|
||||
--label "staging-promotion")
|
||||
|
||||
# Auto-merge (uses App token to bypass branch protection)
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
gh pr merge "$PR_NUM" --merge --auto
|
||||
echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT"
|
||||
echo "Created promotion PR #${PR_NUM}"
|
||||
|
||||
# ── Gate: wait for all checks, process findings, merge or block ──
|
||||
gate:
|
||||
name: Staging Gate
|
||||
needs: [check-changes, tests, e2e, create-promotion-pr]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.tests.result == 'success' &&
|
||||
needs.e2e.result == 'success' &&
|
||||
needs.create-promotion-pr.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gate_passed: ${{ steps.evaluate.outputs.passed }}
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Wait for Claude review on PR
|
||||
id: wait-review
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
run: |
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No PR number — skipping Claude review wait"
|
||||
echo "review_done=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for Claude Code Review check on PR #${PR_NUMBER}..."
|
||||
TIMEOUT=600 # 10 minutes
|
||||
ELAPSED=0
|
||||
INTERVAL=15
|
||||
|
||||
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
|
||||
# Check if the claude-review check has completed
|
||||
STATUS=$(gh pr checks "$PR_NUMBER" --json name,state \
|
||||
--jq '.[] | select(.name == "Claude Code Review") | .state' 2>/dev/null || echo "PENDING")
|
||||
|
||||
if [ "$STATUS" = "SUCCESS" ] || [ "$STATUS" = "FAILURE" ]; then
|
||||
echo "Claude review completed with status: ${STATUS}"
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo "review_done=true" >> "$GITHUB_OUTPUT"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Claude review status: ${STATUS} (${ELAPSED}s elapsed)"
|
||||
sleep "$INTERVAL"
|
||||
ELAPSED=$((ELAPSED + INTERVAL))
|
||||
done
|
||||
|
||||
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
|
||||
echo "::warning::Claude review timed out after ${TIMEOUT}s"
|
||||
echo "review_status=TIMEOUT" >> "$GITHUB_OUTPUT"
|
||||
echo "review_done=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Process Claude review comments and create issues
|
||||
id: process-findings
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
HAS_BLOCKING=false
|
||||
ISSUES_CREATED=0
|
||||
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No PR — skipping finding processing"
|
||||
echo "has_blocking=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get all review comments from Claude on this PR
|
||||
COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" \
|
||||
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, path: .path, line: .line, url: .html_url}]' 2>/dev/null || echo "[]")
|
||||
|
||||
COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
|
||||
echo "Found ${COMMENT_COUNT} Claude review comment(s)"
|
||||
|
||||
# Also check PR review body comments
|
||||
REVIEW_COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
|
||||
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, url: .html_url}]' 2>/dev/null || echo "[]")
|
||||
|
||||
# Combine all comments
|
||||
ALL_COMMENTS=$(echo "$COMMENTS $REVIEW_COMMENTS" | jq -s 'add // []')
|
||||
|
||||
# Parse [SEVERITY:CONFIDENCE] tags from each comment
|
||||
echo "$ALL_COMMENTS" | jq -c '.[]' | while read -r comment; do
|
||||
BODY=$(echo "$comment" | jq -r '.body // ""')
|
||||
URL=$(echo "$comment" | jq -r '.url // ""')
|
||||
FILE=$(echo "$comment" | jq -r '.path // "unknown"')
|
||||
LINE=$(echo "$comment" | jq -r '.line // 0')
|
||||
|
||||
# Extract [SEVERITY:CONFIDENCE] tag
|
||||
TAG=$(echo "$BODY" | grep -oE '\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]' | head -1 || true)
|
||||
if [ -z "$TAG" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
|
||||
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
|
||||
# Strip tag from body for issue description
|
||||
DESC=$(echo "$BODY" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -5)
|
||||
|
||||
echo "Found: [${SEVERITY}:${CONFIDENCE}] in ${FILE}:${LINE}"
|
||||
|
||||
# Determine if this should create an issue (confidence matrix)
|
||||
CREATE_ISSUE=false
|
||||
case "$SEVERITY" in
|
||||
CRITICAL) CREATE_ISSUE=true ;; # Always
|
||||
HIGH) [ "$CONFIDENCE" -ge 50 ] && CREATE_ISSUE=true ;;
|
||||
MEDIUM) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
|
||||
LOW) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
|
||||
esac
|
||||
|
||||
# Check if blocking (CRITICAL ≥80)
|
||||
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -ge 80 ]; then
|
||||
HAS_BLOCKING=true
|
||||
fi
|
||||
|
||||
if [ "$CREATE_ISSUE" = "true" ]; then
|
||||
# Determine labels
|
||||
case "$SEVERITY" in
|
||||
CRITICAL) LABELS="bug,risk: high,staging-ci-review" ;;
|
||||
HIGH) LABELS="bug,risk: medium,staging-ci-review" ;;
|
||||
MEDIUM) LABELS="risk: medium,staging-ci-review" ;;
|
||||
LOW) LABELS="risk: low,staging-ci-review" ;;
|
||||
esac
|
||||
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -lt 50 ]; then
|
||||
LABELS="${LABELS},low-confidence"
|
||||
fi
|
||||
|
||||
TITLE=$(echo "$DESC" | head -1 | cut -c1-80)
|
||||
{
|
||||
echo "## ${SEVERITY} Issue Found by Staging CI Review"
|
||||
echo ""
|
||||
echo "**Severity:** ${SEVERITY}"
|
||||
echo "**Confidence:** ${CONFIDENCE}/100"
|
||||
echo "**File:** \`${FILE}:${LINE}\`"
|
||||
echo "**PR comment:** ${URL}"
|
||||
echo ""
|
||||
echo "### Description"
|
||||
echo "$DESC"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Auto-created by staging-ci Claude Code review*"
|
||||
} > /tmp/issue-body.md
|
||||
|
||||
gh issue create \
|
||||
--title "[${SEVERITY}] ${TITLE}" \
|
||||
--body-file /tmp/issue-body.md \
|
||||
--label "${LABELS}" || echo "::warning::Failed to create issue for ${SEVERITY} finding"
|
||||
ISSUES_CREATED=$((ISSUES_CREATED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Created ${ISSUES_CREATED} issues"
|
||||
echo "has_blocking=${HAS_BLOCKING}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Evaluate gate
|
||||
id: evaluate
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
run: |
|
||||
HAS_BLOCKING="${{ steps.process-findings.outputs.has_blocking }}"
|
||||
SKIP_INPUT="${{ inputs.skip_claude_gate }}"
|
||||
|
||||
if [ "$HAS_BLOCKING" = "true" ]; then
|
||||
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
|
||||
if [ "$SKIP_INPUT" = "true" ]; then
|
||||
echo "::warning::Gate overridden by skip_claude_gate workflow input"
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Blocking promotion due to CRITICAL findings (≥80 confidence)"
|
||||
echo "::error::PR #${PR_NUMBER} left open with review comments"
|
||||
echo "passed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No blocking findings. Gate passed."
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Merge the promotion PR
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER}"
|
||||
gh pr merge "$PR_NUMBER" --merge --auto || echo "::warning::Auto-merge failed for PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# ── Update tested tag on success ─────────────────────────────────
|
||||
update-tag:
|
||||
name: Update staging-tested tag
|
||||
needs: [check-changes, gate]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.gate.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update staging-tested tag
|
||||
run: |
|
||||
git tag -f staging-tested "${{ needs.check-changes.outputs.current_head }}"
|
||||
git push origin staging-tested --force
|
||||
echo "Updated staging-tested tag to ${{ needs.check-changes.outputs.current_head }}"
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────
|
||||
report:
|
||||
name: Staging CI Summary
|
||||
needs: [check-changes, tests, e2e, claude-review, claude-gate, update-tag, promote-to-main]
|
||||
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
|
||||
if: always() && needs.check-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -256,9 +403,12 @@ jobs:
|
||||
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Claude Review | ${{ needs.claude-review.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Claude Gate | ${{ needs.claude-gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Promoted to Main | ${{ needs.promote-to-main.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user