mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
ci: staging branch with batched CI and Claude Code review [skip-regression-check]
Redesign CI to speed up PR feedback by deferring heavy tests to a 30-minute batch on the staging branch. PRs only run fmt + clippy + regression-test-check. The batch CI runs tests, E2E, and a Claude Code review with confidence-scored findings that gate promotion to main. - test.yml: replace PR/push triggers with workflow_call + dispatch - e2e.yml: replace PR paths trigger with workflow_call - coverage.yml: add workflow_call + dispatch triggers - code_style.yml: absorb version-check job from test.yml - staging-ci.yml: new orchestrator (30-min cron, change detection, tests + e2e + claude-review → gate → tag + promote-to-main) - claude-review.yml: new reusable review workflow with 0-100 confidence scoring (CRITICAL ≥80 blocks, ≥50 creates issues, <50 CRITICAL gets low-confidence label) - create-labels.sh: add staging-ci-review, skip-claude-gate, low-confidence labels Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
469a252051
commit
6556d7ebcd
@@ -0,0 +1,340 @@
|
||||
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
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Claude Code Review
|
||||
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: Generate diff context
|
||||
id: diff
|
||||
run: |
|
||||
RANGE="${{ inputs.diff_range }}"
|
||||
if [ -z "$RANGE" ]; then
|
||||
echo "diff_available=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No diff range provided"
|
||||
exit 0
|
||||
fi
|
||||
echo "diff_available=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# File-level diffstat for overview
|
||||
git diff --stat $RANGE > /tmp/diffstat.txt
|
||||
# Commit log with full messages
|
||||
git log --format="- %h %s" $RANGE > /tmp/commits.txt
|
||||
# Full diff
|
||||
git diff $RANGE > /tmp/staging-diff.patch
|
||||
|
||||
# Truncate diff if too large (>200KB) to avoid token limits
|
||||
DIFF_SIZE=$(wc -c < /tmp/staging-diff.patch)
|
||||
if [ "$DIFF_SIZE" -gt 204800 ]; then
|
||||
echo "::warning::Diff is ${DIFF_SIZE} bytes, truncating to 200KB"
|
||||
head -c 204800 /tmp/staging-diff.patch > /tmp/staging-diff-truncated.patch
|
||||
mv /tmp/staging-diff-truncated.patch /tmp/staging-diff.patch
|
||||
fi
|
||||
|
||||
# Build the prompt with actual file contents (not heredoc references)
|
||||
{
|
||||
cat <<'STATIC_PART'
|
||||
You are reviewing a batch of commits that just landed on the staging branch
|
||||
of the IronClaw project (a Rust AI assistant). This is a thorough security
|
||||
and quality review.
|
||||
|
||||
## Your Review Process
|
||||
|
||||
1. **Read CLAUDE.md** for project coding standards and conventions
|
||||
2. **Read the full diff** below to understand what changed
|
||||
3. **Read the complete source files** for every changed file (use the Read tool)
|
||||
to understand the full context, not just the diff hunks
|
||||
4. **Trace data flows** through changed code paths to find bugs
|
||||
5. **Check for security issues** across the OWASP top 10 categories
|
||||
|
||||
## Severity Categories
|
||||
|
||||
### CRITICAL
|
||||
- Security vulnerabilities: injection, XSS, SSRF, path traversal, auth bypass
|
||||
- Data exfiltration risks: secrets in logs, unescaped user data reaching LLM
|
||||
- Memory safety: unsafe blocks, unbounded allocations, panics in prod code
|
||||
- .unwrap() or .expect() in production code (not tests)
|
||||
- Race conditions in concurrent code (Arc/RwLock misuse, TOCTOU)
|
||||
|
||||
### HIGH
|
||||
- Logic bugs that would cause incorrect behavior
|
||||
- Missing error handling that would cause silent failures
|
||||
- Breaking changes to public APIs or database schema
|
||||
- Regression in safety/sanitizer/leak detection layers
|
||||
|
||||
### MEDIUM
|
||||
- Missing tests for new functionality
|
||||
- Unnecessary complexity or poor abstractions
|
||||
- Performance concerns (N+1 queries, unbounded loops)
|
||||
|
||||
### LOW
|
||||
- Documentation gaps, naming suggestions
|
||||
|
||||
## Confidence Scoring
|
||||
|
||||
For EVERY finding, assign a confidence score from 0 to 100:
|
||||
- **90-100**: You are certain this is a real issue. You can point to the exact code and explain why it's wrong.
|
||||
- **70-89**: Very likely a real issue but you'd want a human to verify.
|
||||
- **50-69**: Possibly an issue but you're not fully sure of the context.
|
||||
- **0-49**: Speculative. Might be a false positive.
|
||||
|
||||
Be honest with your confidence. A CRITICAL finding you're unsure about should get a low confidence score.
|
||||
|
||||
## Output Format
|
||||
|
||||
Write your findings to a file called /tmp/review-results.json with this structure:
|
||||
{
|
||||
"summary": "One paragraph overall assessment",
|
||||
"critical": [{"title": "...", "description": "...", "file": "...", "line": 0, "confidence": 85}],
|
||||
"high": [{"title": "...", "description": "...", "file": "...", "line": 0, "confidence": 75}],
|
||||
"medium": [{"title": "...", "description": "...", "file": "...", "line": 0, "confidence": 60}],
|
||||
"low": [{"title": "...", "description": "...", "file": "...", "line": 0, "confidence": 50}]
|
||||
}
|
||||
|
||||
If you find no issues at a given severity, use an empty array.
|
||||
|
||||
## Context
|
||||
|
||||
Changed files:
|
||||
STATIC_PART
|
||||
cat /tmp/diffstat.txt
|
||||
echo ""
|
||||
echo "Commits:"
|
||||
cat /tmp/commits.txt
|
||||
echo ""
|
||||
echo "Diff:"
|
||||
cat /tmp/staging-diff.patch
|
||||
} > /tmp/review-prompt.txt
|
||||
|
||||
- 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: |
|
||||
$(cat /tmp/review-prompt.txt)
|
||||
claude_args: "--max-turns 30"
|
||||
|
||||
- name: Validate and process results
|
||||
id: process-results
|
||||
if: always() && steps.diff.outputs.diff_available == 'true'
|
||||
run: |
|
||||
# Default: no results
|
||||
if [ ! -f /tmp/review-results.json ]; then
|
||||
echo '{"summary":"Claude review did not produce structured output.","critical":[],"high":[],"medium":[],"low":[]}' > /tmp/review-results.json
|
||||
fi
|
||||
|
||||
# Validate JSON
|
||||
if ! jq empty /tmp/review-results.json 2>/dev/null; then
|
||||
echo "::warning::review-results.json is not valid JSON, using fallback"
|
||||
echo '{"summary":"Claude review produced invalid JSON.","critical":[],"high":[],"medium":[],"low":[]}' > /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"
|
||||
@@ -44,15 +44,32 @@ jobs:
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
version-check:
|
||||
name: Version Bump Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check version bumps for changed extensions
|
||||
env:
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: ./scripts/check-version-bumps.sh
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy]
|
||||
needs: [format, clippy, version-check]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.version-check.result }}" == "failure" ]]; then
|
||||
echo "Version bump check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: Code Coverage
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main] # Runs when staging merges to main
|
||||
workflow_call: # Optional: staging-ci can invoke
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
@@ -2,11 +2,8 @@ name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_call: # Called by staging-ci.yml
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/channels/web/**"
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
# ── Step 1: compile once ──────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
name: Staging CI (Batched)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *" # Every 30 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Force run even if no new commits"
|
||||
type: boolean
|
||||
default: false
|
||||
skip_claude_gate:
|
||||
description: "Skip Claude review gate (bypass CRITICAL block)"
|
||||
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
|
||||
|
||||
concurrency:
|
||||
group: staging-ci
|
||||
cancel-in-progress: false # Let running suites finish
|
||||
|
||||
jobs:
|
||||
# ── Gate: 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:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for changes since last tested
|
||||
id: check
|
||||
run: |
|
||||
CURRENT_HEAD=$(git rev-parse HEAD)
|
||||
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if git rev-parse staging-tested >/dev/null 2>&1; then
|
||||
LAST_TESTED=$(git rev-parse staging-tested)
|
||||
else
|
||||
LAST_TESTED=""
|
||||
fi
|
||||
echo "last_tested=${LAST_TESTED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "$LAST_TESTED" ] && [ "$LAST_TESTED" = "$CURRENT_HEAD" ]; then
|
||||
echo "No new commits since last tested (${CURRENT_HEAD})"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "diff_range=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
if [ -n "$LAST_TESTED" ]; then
|
||||
COMMIT_COUNT=$(git rev-list --count "${LAST_TESTED}..HEAD")
|
||||
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
||||
echo "diff_range=${LAST_TESTED}..${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||
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}"
|
||||
echo "diff_range=${MERGE_BASE}..${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Force override from workflow_dispatch
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "Force run requested"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Run full test suite ──────────────────────────────────────────
|
||||
tests:
|
||||
name: Test Suite
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
# ── Run E2E browser tests ────────────────────────────────────────
|
||||
e2e:
|
||||
name: E2E Browser Tests
|
||||
needs: check-changes
|
||||
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
|
||||
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'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gate_passed: ${{ steps.gate.outputs.passed }}
|
||||
steps:
|
||||
- name: Check for skip-claude-gate label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
SKIP=$(gh label list --json name -q '.[].name' | grep -c 'skip-claude-gate' || true)
|
||||
echo "skip_label=${SKIP}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Evaluate gate
|
||||
id: gate
|
||||
run: |
|
||||
HAS_BLOCKING="${{ needs.claude-review.outputs.has_blocking }}"
|
||||
SKIP_LABEL="${{ steps.label-check.outputs.skip_label }}"
|
||||
SKIP_INPUT="${{ inputs.skip_claude_gate }}"
|
||||
|
||||
if [ "$HAS_BLOCKING" = "true" ]; then
|
||||
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
|
||||
if [ "$SKIP_LABEL" -gt 0 ] || [ "$SKIP_INPUT" = "true" ]; then
|
||||
echo "::warning::Gate overridden by skip-claude-gate label or 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
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: Check if staging is ahead of main
|
||||
id: ahead-check
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
git fetch origin main
|
||||
AHEAD=$(git rev-list --count origin/main..origin/staging)
|
||||
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo "Staging is not ahead of main. Nothing to promote."
|
||||
else
|
||||
echo "Staging is ${AHEAD} commits ahead of main."
|
||||
fi
|
||||
|
||||
- name: Create and merge PR to main
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
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 \
|
||||
--title "chore: promote staging to main (${TIMESTAMP})" \
|
||||
--body "## Auto-promotion from staging CI
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
*Auto-created by staging-ci workflow*" \
|
||||
--label "staging-ci-review")
|
||||
|
||||
# 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
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────
|
||||
report:
|
||||
name: Staging CI Summary
|
||||
needs: [check-changes, tests, e2e, claude-review, claude-gate, update-tag, promote-to-main]
|
||||
if: always() && needs.check-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY"
|
||||
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 "| 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"
|
||||
@@ -1,9 +1,7 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_call: # Called by staging-ci.yml
|
||||
workflow_dispatch: # Manual escape hatch
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -81,34 +79,15 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
|
||||
version-check:
|
||||
name: Version Bump Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check version bumps for changed extensions
|
||||
env:
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: ./scripts/check-version-bumps.sh
|
||||
|
||||
# Roll-up job for branch protection
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, version-check]
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
# version-check only runs on PRs, so skip/success are both acceptable
|
||||
if [[ "${{ needs.version-check.result }}" == "failure" ]]; then
|
||||
echo "Version bump check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user