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:
Henry Park
2026-03-07 13:23:02 -08:00
co-authored by Claude Opus 4.6
parent 469a252051
commit 6556d7ebcd
7 changed files with 632 additions and 30 deletions
+340
View File
@@ -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"
+18 -1
View File
@@ -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
+3 -1
View File
@@ -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
+1 -4
View File
@@ -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 ──────────────────────────────────────────────────
+264
View File
@@ -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"
+3 -24
View File
@@ -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