diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3124f564 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,449 @@ +name: CI + +on: + push: + branches: [main, develop, 'claude/**'] + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + # ============================================================================ + # Code Quality Checks (fast, run first) + # ============================================================================ + quality: + name: Code Quality + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install build dependencies + uses: ./.github/actions/install-build-deps + with: + include-just: 'true' + + - name: Run pre-commit hooks + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd + with: + extra_args: --show-diff-on-failure + + - name: Check CMake dependency graph for cycles + run: | + cmake -S . -B build/graphviz -G Ninja --graphviz=build/graphviz/deps.dot -DCMAKE_BUILD_TYPE=Debug 2>/dev/null || true + if [ -f build/graphviz/deps.dot ]; then + echo "CMake dependency graph generated successfully" + python3 << 'EOF' + import re, sys + content = open("build/graphviz/deps.dot").read() + edges = re.findall(r'"([^"]+)" -> "([^"]+)"', content) + adj = {} + for src, dst in edges: + adj.setdefault(src, []).append(dst) + visited, in_stack = set(), set() + def has_cycle(node): + if node in in_stack: return True + if node in visited: return False + visited.add(node); in_stack.add(node) + if any(has_cycle(n) for n in adj.get(node, [])): return True + in_stack.discard(node); return False + if any(has_cycle(n) for n in list(adj)): + print("CYCLE DETECTED in CMake dependency graph!"); sys.exit(1) + print("No cycles detected in CMake dependency graph") + EOF + fi + + # Note: Full static analysis (clang-tidy, cppcheck) takes 30+ minutes + # and should be run locally or in a separate weekly workflow. + # For PR checks, we only verify formatting. + + # ============================================================================ + # Python Tests with Coverage + # ============================================================================ + pytest: + name: Python Tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: quality + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: pip install -e ".[dev]" + + - name: Run pytest with coverage + run: pytest + + - name: Run version bounds test + run: python3 -m pytest tests/test_version_bounds.py -v + + - name: Upload coverage XML + if: always() + uses: actions/upload-artifact@v7 + with: + name: python-coverage + path: coverage.xml + retention-days: 14 + if-no-files-found: ignore + + # ============================================================================ + # Python Quality Checks (mypy type checking) + # ============================================================================ + python-quality: + name: Python Quality (mypy) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: quality + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.9' + + - name: Install mypy and ruff + run: pip install "mypy>=1.8.0" "conan>=2.0.0" "ruff>=0.1.0" + + - name: Lint with ruff + run: ruff check src/ tests/ + + - name: Type check (mypy) + run: mypy conanfile.py + + # ============================================================================ + # Build and Test Matrix (all sanitizers) + # ============================================================================ + sanitizers: + + name: Test (${{ matrix.sanitizer }}) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + needs: quality + + strategy: + fail-fast: false + matrix: + sanitizer: [asan, ubsan, tsan, lsan] + include: + - sanitizer: asan + name: AddressSanitizer + - sanitizer: ubsan + name: UndefinedBehaviorSanitizer + - sanitizer: tsan + name: ThreadSanitizer + - sanitizer: lsan + name: LeakSanitizer + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install build dependencies + uses: ./.github/actions/install-build-deps + + - name: Cache Conan packages + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} + restore-keys: | + conan-${{ runner.os }}- + + - name: Cache FetchContent downloads + uses: actions/cache@v5 + with: + path: build/x86.debug.${{ matrix.sanitizer }}/_deps + key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-${{ runner.os }}- + + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Configure sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + + - name: Install project dependencies + run: make deps.native + + - name: Build with ${{ matrix.name }} + run: make compile.debug.${{ matrix.sanitizer }}.native + + - name: Show sccache stats + if: always() + run: sccache --show-stats + + - name: Run tests with ${{ matrix.name }} + run: make test.debug.${{ matrix.sanitizer }}.native + env: + # TSan: suppress ConcurrentQueue false positives; 600s timeout set in Makefile + TSAN_OPTIONS: ${{ matrix.sanitizer == 'tsan' && format('suppressions={0}/tsan.supp:second_deadlock_stack=1', github.workspace) || '' }} + + - name: Upload test logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-logs-${{ matrix.sanitizer }} + path: build/x86.debug.${{ matrix.sanitizer }}/Testing/ + retention-days: 7 + + # ============================================================================ + # Benchmarks (release build) + # ============================================================================ + benchmarks: + name: Benchmarks + runs-on: ubuntu-24.04 + timeout-minutes: 20 + needs: quality + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install build dependencies + uses: ./.github/actions/install-build-deps + + - name: Cache Conan packages + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} + restore-keys: | + conan-${{ runner.os }}- + + - name: Cache FetchContent downloads + uses: actions/cache@v5 + with: + path: build/x86.release/_deps + key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-${{ runner.os }}- + + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Configure sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + + - name: Install project dependencies + run: make deps.native + + - name: Build release + run: make compile.release.native + + - name: Show sccache stats + if: always() + run: sccache --show-stats + + - name: Run benchmarks + run: make benchmark.native || true # Don't fail on benchmark results + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: benchmark-results + path: build/reports/benchmarks/ + retention-days: 30 + if-no-files-found: ignore + + # ============================================================================ + # Coverage Report + # ============================================================================ + coverage: + name: Code Coverage + runs-on: ubuntu-24.04 + timeout-minutes: 20 + needs: quality + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install build dependencies + uses: ./.github/actions/install-build-deps + with: + include-coverage: 'true' + + - name: Cache Conan packages + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} + restore-keys: | + conan-${{ runner.os }}- + + - name: Cache FetchContent downloads + uses: actions/cache@v5 + with: + path: build/x86.debug.coverage/_deps + key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-${{ runner.os }}- + + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Configure sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + + - name: Install project dependencies + run: make deps.native + + - name: Build with coverage + run: make compile.debug.coverage.native + + - name: Show sccache stats + if: always() + run: sccache --show-stats + + - name: Run tests for coverage + run: make test.debug.coverage.native + + - name: Generate coverage report + run: | + chmod +x scripts/generate_coverage.sh + BUILD_DIR=build/x86.coverage.debug ./scripts/generate_coverage.sh + + - name: Upload coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: build/coverage/html/ + retention-days: 30 + if-no-files-found: ignore + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: build/coverage/coverage_filtered.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + # ============================================================================ + # Summary Report + # ============================================================================ + summary: + name: CI Summary + runs-on: ubuntu-24.04 + needs: [quality, pytest, python-quality, sanitizers, benchmarks, coverage] + if: always() + + steps: + - name: Check results + run: | + echo "Quality: ${{ needs.quality.result }}" + echo "Python Tests: ${{ needs.pytest.result }}" + echo "Python Quality (mypy): ${{ needs.python-quality.result }}" + echo "Sanitizers: ${{ needs.sanitizers.result }}" + echo "Benchmarks: ${{ needs.benchmarks.result }}" + echo "Coverage: ${{ needs.coverage.result }}" + + # Fail if critical jobs failed + if [ "${{ needs.quality.result }}" != "success" ]; then + echo "::error::Quality checks failed" + exit 1 + fi + if [ "${{ needs.pytest.result }}" != "success" ]; then + echo "::error::Python tests failed" + exit 1 + fi + if [ "${{ needs.python-quality.result }}" != "success" ]; then + echo "::error::Python quality checks failed" + exit 1 + fi + if [ "${{ needs.sanitizers.result }}" != "success" ]; then + echo "::error::Sanitizer tests failed" + exit 1 + fi + + echo "CI passed successfully" + + - name: Post PR summary + if: github.event_name == 'pull_request' + uses: actions/github-script@v9 + with: + script: | + const sanitizerResult = '${{ needs.sanitizers.result }}'; + const qualityResult = '${{ needs.quality.result }}'; + const pytestResult = '${{ needs.pytest.result }}'; + const pythonQualityResult = '${{ needs.python-quality.result }}'; + const benchmarkResult = '${{ needs.benchmarks.result }}'; + const coverageResult = '${{ needs.coverage.result }}'; + + const statusIcon = (result) => result === 'success' ? '✅' : result === 'failure' ? '❌' : '⚠️'; + + const body = `## CI Summary + + | Check | Status | + |-------|--------| + | Code Quality | ${statusIcon(qualityResult)} ${qualityResult} | + | Python Tests | ${statusIcon(pytestResult)} ${pytestResult} | + | Python Quality (mypy) | ${statusIcon(pythonQualityResult)} ${pythonQualityResult} | + | Sanitizers (ASan, UBSan, TSan, LSan, MSan) | ${statusIcon(sanitizerResult)} ${sanitizerResult} | + | Benchmarks | ${statusIcon(benchmarkResult)} ${benchmarkResult} | + | Coverage | ${statusIcon(coverageResult)} ${coverageResult} | + + [View full run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + `; + + // Find and update or create comment + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existingComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes(marker) + ); + + const fullBody = marker + '\n' + body; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: fullBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: fullBody + }); + }