diff --git a/.github/scripts/create-labels.sh b/.github/scripts/create-labels.sh index 66f07ea9..3e5368b1 100755 --- a/.github/scripts/create-labels.sh +++ b/.github/scripts/create-labels.sh @@ -64,6 +64,9 @@ create "scope: dependencies" "90A4AE" "Dependency updates" echo "==> Creating workflow labels..." create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test" +create "staging-ci-review" "D93F0B" "Auto-created by staging CI Claude Code review" +create "skip-claude-gate" "FBCA04" "Override: bypass Claude CRITICAL gate on staging CI" +create "low-confidence" "C5DEF5" "Claude review finding with <50 confidence" echo "==> Creating contributor labels..." create "contributor: new" "FFF9C4" "First-time contributor" diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000..fb4c02e5 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -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" diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 2493a95e..529c93df 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8489d69d..aa05ba68 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3dc95a2d..cdd3e377 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 ────────────────────────────────────────────────── diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml new file mode 100644 index 00000000..5481eca5 --- /dev/null +++ b/.github/workflows/staging-ci.yml @@ -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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73b39261..cdd64fd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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