Build and Test #615
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Test | |
| on: | |
| push: # Trigger on every commit to any branch | |
| pull_request: | |
| branches: [ main, develop ] | |
| schedule: | |
| # Run nightly tests at 2 AM UTC | |
| - cron: '0 2 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| run_performance_tests: | |
| description: 'Run performance benchmarks' | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| checks: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| DOTNET_NOLOGO: true | |
| DOTNET_CLI_TELEMETRY_OPTOUT: true | |
| DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true | |
| NUGET_XMLDOC_MODE: skip | |
| jobs: | |
| # Matrix job for unit tests across platforms and frameworks | |
| unit-tests: | |
| name: Unit Tests (${{ matrix.os }}, ${{ matrix.framework }}) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| framework: [net8.0, net9.0, net10.0] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: | | |
| 8.0.x | |
| 9.0.x | |
| 10.0.x | |
| dotnet-quality: 'ga' | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props', 'global.json', 'Directory.Build.*') }} | |
| restore-keys: | | |
| ${{ runner.os }}-nuget- | |
| - name: Restore dependencies | |
| run: dotnet restore --verbosity minimal | |
| - name: Build solution | |
| run: dotnet build --configuration Release --no-restore --verbosity minimal | |
| - name: Run unit tests | |
| shell: bash | |
| run: | | |
| dotnet test \ | |
| --configuration Release \ | |
| --no-build \ | |
| --verbosity minimal \ | |
| --framework ${{ matrix.framework }} \ | |
| --filter "Category=Unit" \ | |
| --collect:"XPlat Code Coverage" \ | |
| --results-directory ./test-results \ | |
| --logger "trx;LogFileName=unit-tests-${{ matrix.os }}-${{ matrix.framework }}.trx" \ | |
| -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura | |
| - name: Upload test results | |
| uses: actions/upload-artifact@v5 | |
| if: always() | |
| with: | |
| name: test-results-${{ matrix.os }}-${{ matrix.framework }} | |
| path: | | |
| ./test-results/**/*.trx | |
| ./test-results/**/*.xml | |
| retention-days: 7 | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@v5 | |
| if: matrix.os == 'ubuntu-latest' && matrix.framework == 'net8.0' | |
| with: | |
| directory: ./test-results | |
| flags: unit-tests | |
| fail_ci_if_error: false | |
| # Performance regression detection | |
| performance-regression: | |
| name: Performance Regression Analysis | |
| needs: unit-tests | |
| if: always() && (github.event_name == 'schedule' || github.event.inputs.run_performance_tests == 'true') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 8.0.x | |
| - name: Restore dependencies | |
| run: dotnet restore | |
| - name: Build benchmarks | |
| run: dotnet build tests/HeroMessaging.Benchmarks --configuration Release --no-restore | |
| - name: Download baseline benchmarks | |
| uses: actions/download-artifact@v6 | |
| continue-on-error: true # Baseline may not exist on first run | |
| with: | |
| name: performance-baseline | |
| path: ./benchmark-baseline | |
| - name: Run performance benchmarks | |
| run: | | |
| dotnet run \ | |
| --project tests/HeroMessaging.Benchmarks \ | |
| --configuration Release \ | |
| -- --exporters json \ | |
| --artifacts ./benchmark-results \ | |
| --job short | |
| - name: Analyze performance regression | |
| continue-on-error: true # Informational only - don't fail CI | |
| run: | | |
| dotnet run \ | |
| --project tests/HeroMessaging.Benchmarks \ | |
| --configuration Release \ | |
| -- --analyze-regression \ | |
| --current ./benchmark-results \ | |
| --baseline ./benchmark-baseline \ | |
| --threshold 10 | |
| - name: Upload current benchmarks as baseline | |
| uses: actions/upload-artifact@v5 | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| with: | |
| name: performance-baseline | |
| path: ./benchmark-results/**/*.json | |
| retention-days: 90 | |
| - name: Comment performance results | |
| uses: actions/github-script@v8 | |
| if: github.event_name == 'pull_request' | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = './benchmark-results/regression-report.md'; | |
| if (fs.existsSync(path)) { | |
| const report = fs.readFileSync(path, 'utf8'); | |
| github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: `## Performance Analysis\n\n${report}` | |
| }); | |
| } | |
| # Quality gates and final validation | |
| quality-gates: | |
| name: Quality Gates & Validation | |
| needs: [unit-tests] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Download all test results | |
| uses: actions/download-artifact@v6 | |
| with: | |
| path: ./all-test-results | |
| continue-on-error: true | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 8.0.x | |
| - name: Install ReportGenerator | |
| run: dotnet tool install --global dotnet-reportgenerator-globaltool | |
| - name: Generate combined coverage report | |
| continue-on-error: true | |
| run: | | |
| # Check if any coverage files exist | |
| if find ./all-test-results -name "coverage.cobertura.xml" -type f | grep -q .; then | |
| echo "Found coverage files, generating report..." | |
| echo "" | |
| echo "Coverage filters:" | |
| echo " ✓ Including: Production code from src/" | |
| echo " ✗ Excluding: System.*, Microsoft.* framework classes" | |
| echo " ✗ Excluding: *.Tests, *Benchmarks, *.Examples, *.Demo assemblies" | |
| echo " ✗ Excluding: Exception classes (*Exception)" | |
| echo "" | |
| echo "ℹ️ Note: Simple DTOs, records, and value objects are included in coverage." | |
| echo " The 80% threshold accounts for both logic and data structures." | |
| echo "" | |
| reportgenerator \ | |
| -reports:"./all-test-results/**/coverage.cobertura.xml" \ | |
| -targetdir:"./coverage-report" \ | |
| -reporttypes:"Html;Badges;TextSummary" \ | |
| -classfilters:"-System.*;-Microsoft.*;-*Exception;-*Exception`*" \ | |
| -assemblyfilters:"-*.Tests;-*.Tests.*;-*Benchmarks;-*Benchmarks.*;-*.Examples;-*.Examples.*;-*.Demo;-*.Demo.*" | |
| else | |
| echo "No coverage files found, skipping report generation" | |
| mkdir -p ./coverage-report | |
| echo "Line coverage: 0%" > ./coverage-report/Summary.txt | |
| fi | |
| - name: Read coverage summary | |
| id: coverage | |
| run: | | |
| if [ -f "./coverage-report/Summary.txt" ]; then | |
| COVERAGE=$(grep "Line coverage:" ./coverage-report/Summary.txt | grep -o '[0-9.]*%' | head -1 | sed 's/%//') | |
| echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT | |
| echo "Coverage: $COVERAGE%" | |
| else | |
| echo "coverage=0" >> $GITHUB_OUTPUT | |
| echo "No coverage data found" | |
| fi | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: coverage-report | |
| path: ./coverage-report/**/* | |
| retention-days: 30 | |
| - name: Check quality gates | |
| run: | | |
| echo "Checking quality gates..." | |
| # Check test results first | |
| UNIT_TESTS_PASSED="${{ needs.unit-tests.result }}" | |
| echo "Unit tests: $UNIT_TESTS_PASSED" | |
| echo "ℹ️ Integration tests run in separate workflow" | |
| if [[ "$UNIT_TESTS_PASSED" != "success" && "$UNIT_TESTS_PASSED" != "skipped" ]]; then | |
| echo "❌ Unit tests failed" | |
| exit 1 | |
| fi | |
| # Coverage threshold check (constitutional requirement: 80% minimum) | |
| COVERAGE=${{ steps.coverage.outputs.coverage }} | |
| if [[ "$UNIT_TESTS_PASSED" == "skipped" ]]; then | |
| echo "⚠️ No coverage data (no unit tests ran)" | |
| else | |
| echo "📊 Production Code Coverage (Unit Tests): $COVERAGE%" | |
| echo "📋 Threshold: 80% (per CLAUDE.md constitutional requirement)" | |
| echo "ℹ️ Measuring: src/ assemblies only (excluding tests, benchmarks, examples)" | |
| echo "" | |
| # Check if coverage meets 80% threshold | |
| if (( $(echo "$COVERAGE < 80" | bc -l) )); then | |
| echo "❌ Coverage below 80% threshold: $COVERAGE%" | |
| echo "" | |
| echo "Constitutional requirement: 80% minimum coverage (100% for public APIs)" | |
| echo "Note: This measures unit test coverage of production code only." | |
| echo "Integration tests run separately in the 'Integration Tests' workflow." | |
| exit 1 | |
| else | |
| echo "✅ Coverage meets 80% threshold" | |
| fi | |
| fi | |
| echo "✅ All quality gates passed" | |
| - name: Update status badges | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| run: | | |
| echo "Updating status badges..." | |
| # This would typically update README badges or external services | |
| # Build release packages for main branch (used by create-release workflow) | |
| build-release-packages: | |
| name: Build Release Packages | |
| needs: quality-gates | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: | | |
| 8.0.x | |
| 9.0.x | |
| - name: Restore dependencies | |
| run: dotnet restore | |
| - name: Build solution | |
| run: dotnet build --configuration Release --no-restore | |
| - name: Generate build number | |
| id: build-number | |
| run: | | |
| # Extract base version from project file with fallback | |
| BASE_VERSION=$(grep -oP '<Version>\K[^<]+' src/HeroMessaging/HeroMessaging.csproj 2>/dev/null || \ | |
| grep '<Version>' src/HeroMessaging/HeroMessaging.csproj 2>/dev/null | sed -n 's/.*<Version>\(.*\)<\/Version>.*/\1/p' || \ | |
| echo "0.0.0") | |
| # Extract and sanitize branch name (replace / with -) | |
| BRANCH_NAME="${GITHUB_REF#refs/heads/}" | |
| BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/\//-/g') | |
| # Build number components | |
| BUILD_DATE=$(date -u +%Y%m%d) | |
| RUN_NUMBER=${{ github.run_number }} | |
| SHORT_SHA=${GITHUB_SHA:0:7} | |
| # Construct build number: branch-date.run.hash | |
| BUILD_NUMBER="${BRANCH_NAME}-${BUILD_DATE}.${RUN_NUMBER}.${SHORT_SHA}" | |
| FULL_VERSION="${BASE_VERSION}-ci.${BUILD_NUMBER}" | |
| echo "build-number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT | |
| echo "full-version=${FULL_VERSION}" >> $GITHUB_OUTPUT | |
| echo "📋 Build Information:" | |
| echo " Base Version: ${BASE_VERSION}" | |
| echo " Branch: ${BRANCH_NAME}" | |
| echo " Date: ${BUILD_DATE}" | |
| echo " Run Number: ${RUN_NUMBER}" | |
| echo " Git Hash: ${SHORT_SHA}" | |
| echo " Build Number: ${BUILD_NUMBER}" | |
| echo " Full Version: ${FULL_VERSION}" | |
| - name: Pack NuGet packages | |
| run: | | |
| mkdir -p ./release-packages | |
| dotnet pack src/HeroMessaging/HeroMessaging.csproj \ | |
| --configuration Release \ | |
| --no-build \ | |
| --output ./release-packages \ | |
| -p:PackageVersion=${{ steps.build-number.outputs.full-version }} \ | |
| -p:IncludeSymbols=true \ | |
| -p:SymbolPackageFormat=snupkg | |
| dotnet pack src/HeroMessaging.Abstractions/HeroMessaging.Abstractions.csproj \ | |
| --configuration Release \ | |
| --no-build \ | |
| --output ./release-packages \ | |
| -p:PackageVersion=${{ steps.build-number.outputs.full-version }} \ | |
| -p:IncludeSymbols=true \ | |
| -p:SymbolPackageFormat=snupkg | |
| - name: Generate SBOM (Software Bill of Materials) | |
| run: | | |
| echo "📋 Generating SBOM for supply chain security..." | |
| # Install Microsoft SBOM tool | |
| dotnet tool install --global Microsoft.Sbom.DotNetTool || dotnet tool update --global Microsoft.Sbom.DotNetTool | |
| # Generate SBOM for the main project | |
| sbom-tool generate \ | |
| -b ./src/HeroMessaging \ | |
| -bc ./src/HeroMessaging \ | |
| -pn HeroMessaging \ | |
| -pv ${{ steps.build-number.outputs.full-version }} \ | |
| -ps "KoalaFacts" \ | |
| -nsb https://github.com/KoalaFacts/HeroMessaging \ | |
| -m ./release-packages/sbom | |
| # Generate SBOM for abstractions | |
| sbom-tool generate \ | |
| -b ./src/HeroMessaging.Abstractions \ | |
| -bc ./src/HeroMessaging.Abstractions \ | |
| -pn HeroMessaging.Abstractions \ | |
| -pv ${{ steps.build-number.outputs.full-version }} \ | |
| -ps "KoalaFacts" \ | |
| -nsb https://github.com/KoalaFacts/HeroMessaging \ | |
| -m ./release-packages/sbom-abstractions | |
| echo "✅ SBOM generated successfully" | |
| ls -lh ./release-packages/sbom*/ | |
| ls -lh ./release-packages/sbom*/**/*.spdx.json || echo "SPDX files location may vary" | |
| - name: List packages | |
| run: | | |
| echo "📦 Packages created:" | |
| ls -lh ./release-packages/ | |
| echo "" | |
| echo "📋 SBOM files:" | |
| find ./release-packages -name "*.spdx.json" -o -name "_manifest" | head -10 | |
| echo "" | |
| echo "Commit SHA: ${GITHUB_SHA}" | |
| echo "Short SHA: ${GITHUB_SHA:0:7}" | |
| echo "Build Number: ${{ steps.build-number.outputs.build-number }}" | |
| echo "Full Version: ${{ steps.build-number.outputs.full-version }}" | |
| - name: Upload release packages | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: release-packages-${{ github.sha }} | |
| path: ./release-packages/* | |
| retention-days: 90 # Keep for 90 days for releases | |
| - name: Upload release packages (latest) | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: release-packages-latest | |
| path: ./release-packages/* | |
| retention-days: 90 | |
| - name: Upload SBOM artifacts | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: sbom-${{ github.sha }} | |
| path: ./release-packages/sbom*/**/* | |
| retention-days: 90 |