diff --git a/.github/actions/validate-coverage/README.md b/.github/actions/validate-coverage/README.md new file mode 100644 index 000000000..399918506 --- /dev/null +++ b/.github/actions/validate-coverage/README.md @@ -0,0 +1,270 @@ +# Validate Coverage Action + +A reusable GitHub Action that validates code coverage against a minimum threshold with intelligent multi-stage fallback analysis. + +## Features + +- **Multi-Stage Fallback**: Tries multiple methods to extract coverage data + 1. Step outputs from CodeCoverageSummary actions (OpenCover โ†’ Cobertura โ†’ Fallback) + 2. Direct XML file analysis when step outputs unavailable + 3. Regex-based extraction from line-rate/sequenceCoverage attributes + +- **Flexible Configuration**: + - Customizable coverage directory + - Adjustable threshold percentage + - Strict/lenient mode for CI/CD flexibility + +- **Comprehensive Debugging**: + - Detailed logs showing data sources + - Coverage file discovery information + - Clear validation results and recommendations + +## Usage + +### Basic Example + +```yaml +- name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + coverage-directory: './coverage' + threshold: '70' + strict-mode: 'true' +``` + +### With CodeCoverageSummary Integration + +```yaml +- name: Generate Coverage Summary (OpenCover) + id: coverage_opencover + uses: irongut/CodeCoverageSummary@v1.3.0 + continue-on-error: true + with: + filename: coverage/**/*.opencover.xml + format: markdown + +- name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + threshold: '70' + strict-mode: 'false' + opencover-output: ${{ steps.coverage_opencover.outputs.line-rate }} + opencover-outcome: ${{ steps.coverage_opencover.outcome }} +``` + +### Multiple Format Fallback + +```yaml +- name: Generate Coverage Summary (OpenCover) + id: coverage_opencover + uses: irongut/CodeCoverageSummary@v1.3.0 + continue-on-error: true + with: + filename: coverage/**/*.opencover.xml + +- name: Generate Coverage Summary (Cobertura) + id: coverage_cobertura + if: steps.coverage_opencover.outcome != 'success' + uses: irongut/CodeCoverageSummary@v1.3.0 + continue-on-error: true + with: + filename: coverage/**/*.cobertura.xml + +- name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + threshold: '70' + strict-mode: ${{ github.event_name == 'push' }} + opencover-output: ${{ steps.coverage_opencover.outputs.line-rate }} + opencover-outcome: ${{ steps.coverage_opencover.outcome }} + cobertura-output: ${{ steps.coverage_cobertura.outputs.line-rate }} + cobertura-outcome: ${{ steps.coverage_cobertura.outcome }} +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `coverage-directory` | Directory containing coverage XML files | No | `./coverage` | +| `threshold` | Minimum coverage percentage required (0-100) | No | `70` | +| `strict-mode` | Whether to fail workflow when coverage is below threshold | No | `true` | +| `opencover-output` | Line coverage from OpenCover CodeCoverageSummary step | No | `''` | +| `opencover-outcome` | Outcome from OpenCover step (success/failure/skipped) | No | `''` | +| `cobertura-output` | Line coverage from Cobertura CodeCoverageSummary step | No | `''` | +| `cobertura-outcome` | Outcome from Cobertura step (success/failure/skipped) | No | `''` | +| `fallback-output` | Line coverage from fallback CodeCoverageSummary step | No | `''` | +| `fallback-outcome` | Outcome from fallback step (success/failure/skipped) | No | `''` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `coverage-percentage` | Final coverage percentage determined by the action | +| `coverage-source` | Source of the coverage data (OpenCover, Cobertura, Direct Analysis, etc.) | +| `threshold-met` | Whether the coverage threshold was met (true/false) | + +## Coverage Data Sources + +The action tries to extract coverage data in the following order: + +1. **OpenCover Step Output**: From `irongut/CodeCoverageSummary` analyzing `*.opencover.xml` +2. **Cobertura Step Output**: From `irongut/CodeCoverageSummary` analyzing `*.cobertura.xml` +3. **Fallback Step Output**: From `irongut/CodeCoverageSummary` analyzing any `*.xml` +4. **Direct OpenCover Analysis**: Regex extraction from `*.opencover.xml` files +5. **Direct Cobertura Analysis**: Regex extraction from `*.cobertura.xml` files +6. **Direct XML Analysis**: Regex extraction from any `*.xml` files + +## Strict vs Lenient Mode + +### Strict Mode (`strict-mode: 'true'`) +- Fails the workflow if coverage is below threshold +- Fails if coverage data cannot be extracted +- Recommended for: main branch, release workflows, production deployments + +### Lenient Mode (`strict-mode: 'false'`) +- Logs warnings but continues workflow execution +- Useful during development when coverage is being improved +- Recommended for: feature branches, draft PRs, development workflows + +## Examples + +### Development Workflow (Lenient) + +```yaml +name: Development CI + +on: + pull_request: + types: [opened, synchronize] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Tests + run: dotnet test --collect:"XPlat Code Coverage" + + - name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + threshold: '70' + strict-mode: 'false' # Warn but don't fail +``` + +### Release Workflow (Strict) + +```yaml +name: Release + +on: + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Tests + run: dotnet test --collect:"XPlat Code Coverage" + + - name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + threshold: '80' + strict-mode: 'true' # Fail if coverage insufficient +``` + +### Nightly Build with Dynamic Threshold + +```yaml +name: Nightly Coverage Check + +on: + schedule: + - cron: '0 2 * * *' + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Tests + run: dotnet test --collect:"XPlat Code Coverage" + + - name: Validate Coverage + uses: ./.github/actions/validate-coverage + with: + threshold: ${{ vars.NIGHTLY_COVERAGE_THRESHOLD || '75' }} + strict-mode: 'true' +``` + +## Troubleshooting + +### Coverage file not found +**Problem**: Action logs show "Coverage directory not found" or "No coverage files found" + +**Solutions**: +1. Verify coverage files are generated: `find . -name "*.xml" -type f` +2. Check `coverage-directory` input matches actual location +3. Ensure test command includes coverage collection: + ```yaml + dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage + ``` + +### Percentage extraction failed +**Problem**: "Coverage files found but percentage extraction failed" + +**Solutions**: +1. Verify XML format is OpenCover or Cobertura +2. Check for `line-rate` or `sequenceCoverage` attributes in XML +3. Use CodeCoverageSummary step outputs instead of direct analysis + +### Threshold not met +**Problem**: Coverage below required threshold + +**Solutions**: +1. Review detailed coverage report from CodeCoverageSummary +2. Temporarily set `strict-mode: 'false'` to allow development +3. Lower threshold during coverage improvement phase +4. Add more unit tests to increase coverage + +## Contributing + +When modifying this action: + +1. **Test locally** using `act` or similar tools +2. **Update README** if inputs/outputs change +3. **Maintain backward compatibility** when possible +4. **Add comments** explaining complex logic +5. **Test all fallback paths** to ensure robustness + +## Design Decisions + +### Why Multi-Stage Fallback? +Different CI environments and coverage tools produce varying XML formats. The fallback strategy ensures reliability across: +- Different .NET versions (coverlet output format changes) +- Different coverage collectors (OpenCover, Cobertura, etc.) +- CI environment differences (GitHub Actions, Azure DevOps, etc.) + +### Why Composite Action? +Using `composite` instead of Docker or JavaScript allows: +- Faster execution (no container build/pull) +- Better compatibility across runners (Linux, macOS, Windows) +- Easier debugging (shell scripts visible in logs) +- No additional dependencies + +### Why Bash Instead of PowerShell? +While the main project uses PowerShell, bash provides: +- Better portability across GitHub-hosted runners +- Simpler string manipulation for XML parsing +- Consistent behavior between Ubuntu/macOS runners +- Fallback compatibility with Windows Git Bash + +## License + +Same as parent repository (see root LICENSE file). diff --git a/.github/actions/validate-coverage/action.yml b/.github/actions/validate-coverage/action.yml new file mode 100644 index 000000000..8a5a00e8d --- /dev/null +++ b/.github/actions/validate-coverage/action.yml @@ -0,0 +1,288 @@ +name: 'Validate Code Coverage' +description: 'Validates code coverage against a minimum threshold with multi-stage fallback analysis' +author: 'MeAjudaAi Team' + +inputs: + coverage-directory: + description: 'Directory containing coverage XML files' + required: false + default: './coverage' + + threshold: + description: 'Minimum coverage percentage required (0-100)' + required: false + default: '70' + + strict-mode: + description: 'Whether to fail the workflow when coverage is below threshold (true/false)' + required: false + default: 'true' + + opencover-output: + description: 'Line coverage rate from OpenCover CodeCoverageSummary step' + required: false + default: '' + + opencover-outcome: + description: 'Outcome from OpenCover CodeCoverageSummary step (success/failure/skipped)' + required: false + default: '' + + cobertura-output: + description: 'Line coverage rate from Cobertura CodeCoverageSummary step' + required: false + default: '' + + cobertura-outcome: + description: 'Outcome from Cobertura CodeCoverageSummary step (success/failure/skipped)' + required: false + default: '' + + fallback-output: + description: 'Line coverage rate from fallback CodeCoverageSummary step' + required: false + default: '' + + fallback-outcome: + description: 'Outcome from fallback CodeCoverageSummary step (success/failure/skipped)' + required: false + default: '' + +outputs: + coverage-percentage: + description: 'Final coverage percentage determined by the action' + value: ${{ steps.validate.outputs.coverage-percentage }} + + coverage-source: + description: 'Source of the coverage data (OpenCover, Cobertura, Direct Analysis, etc.)' + value: ${{ steps.validate.outputs.coverage-source }} + + threshold-met: + description: 'Whether the coverage threshold was met (true/false)' + value: ${{ steps.validate.outputs.threshold-met }} + +runs: + using: 'composite' + steps: + - name: Validate Coverage Thresholds + id: validate + shell: bash + run: | + echo "๐ŸŽฏ VALIDATING COVERAGE THRESHOLDS" + echo "=================================" + echo "Configuration:" + echo " - Coverage directory: ${{ inputs.coverage-directory }}" + echo " - Minimum threshold: ${{ inputs.threshold }}%" + echo " - Strict mode: ${{ inputs.strict-mode }}" + echo "" + + # Check step outcomes first + opencover_success="${{ inputs.opencover-outcome }}" + cobertura_success="${{ inputs.cobertura-outcome }}" + fallback_success="${{ inputs.fallback-outcome }}" + + echo "Debug: Step Outcomes" + echo " - OpenCover: ${opencover_success:-not_run}" + echo " - Cobertura: ${cobertura_success:-not_run}" + echo " - Fallback: ${fallback_success:-not_run}" + echo "" + + # Get coverage percentages (if available) + opencover_line_rate="${{ inputs.opencover-output }}" + cobertura_line_rate="${{ inputs.cobertura-output }}" + fallback_line_rate="${{ inputs.fallback-output }}" + + echo "Debug: Step Outputs" + echo " - OpenCover line rate: ${opencover_line_rate:-not_available}" + echo " - Cobertura line rate: ${cobertura_line_rate:-not_available}" + echo " - Fallback line rate: ${fallback_line_rate:-not_available}" + echo "" + + # Check if coverage files exist for debugging + echo "Debug: Coverage Files" + if [ -d "${{ inputs.coverage-directory }}" ]; then + FILE_COUNT=$(find "${{ inputs.coverage-directory }}" -name "*.xml" -type f 2>/dev/null | wc -l) + echo " - Found $FILE_COUNT XML file(s) in coverage directory" + if [ "$FILE_COUNT" -gt 0 ]; then + echo " - Sample files:" + find "${{ inputs.coverage-directory }}" -name "*.xml" -type f 2>/dev/null | head -3 | sed 's/^/ /' + fi + else + echo " - Coverage directory not found: ${{ inputs.coverage-directory }}" + fi + echo "" + + # Stage 1: Try step outputs first + coverage_rate="" + coverage_source="" + + if [ "$opencover_success" = "success" ] && [ -n "$opencover_line_rate" ]; then + coverage_rate="$opencover_line_rate" + coverage_source="OpenCover" + echo "๐Ÿ“Š Using OpenCover coverage: ${coverage_rate}%" + elif [ "$cobertura_success" = "success" ] && [ -n "$cobertura_line_rate" ]; then + coverage_rate="$cobertura_line_rate" + coverage_source="Cobertura" + echo "๐Ÿ“Š Using Cobertura coverage: ${coverage_rate}%" + elif [ "$fallback_success" = "success" ] && [ -n "$fallback_line_rate" ]; then + coverage_rate="$fallback_line_rate" + coverage_source="Fallback XML" + echo "๐Ÿ“Š Using fallback coverage: ${coverage_rate}%" + else + # Stage 2: Direct file analysis when steps fail but files exist + echo "๐Ÿ” Step outputs unavailable, analyzing coverage files directly..." + + COVERAGE_FILE="" + if [ -d "${{ inputs.coverage-directory }}" ]; then + # Try OpenCover format first + if OPENCOVER_FILE=$(find "${{ inputs.coverage-directory }}" -name "*.opencover.xml" -type f | head -1) && [ -n "$OPENCOVER_FILE" ]; then + COVERAGE_FILE="$OPENCOVER_FILE" + coverage_source="OpenCover (Direct Analysis)" + # Then Cobertura format + elif COBERTURA_FILE=$(find "${{ inputs.coverage-directory }}" -name "*.cobertura.xml" -type f | head -1) && [ -n "$COBERTURA_FILE" ]; then + COVERAGE_FILE="$COBERTURA_FILE" + coverage_source="Cobertura (Direct Analysis)" + # Finally any XML file + elif ANY_XML=$(find "${{ inputs.coverage-directory }}" -name "*.xml" -type f | head -1) && [ -n "$ANY_XML" ]; then + COVERAGE_FILE="$ANY_XML" + coverage_source="XML (Direct Analysis)" + fi + fi + + if [ -n "$COVERAGE_FILE" ] && [ -f "$COVERAGE_FILE" ]; then + echo "๐Ÿ“ Analyzing coverage file: $COVERAGE_FILE" + + # Stage 3: Extract coverage percentage from XML attributes + if command -v grep >/dev/null 2>&1; then + # Look for line-rate (Cobertura format) or sequenceCoverage (OpenCover format) + LINE_RATE=$(grep -o 'line-rate="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) + if [ -z "$LINE_RATE" ]; then + LINE_RATE=$(grep -o 'sequenceCoverage="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) + fi + + if [ -n "$LINE_RATE" ]; then + # Convert decimal to percentage if needed (0.xx or .xx to xx%) + INT_PART=$(echo "$LINE_RATE" | cut -d'.' -f1) + # Treat empty (e.g., ".5") or "0" (e.g., "0.83") as decimal format + if [ "$INT_PART" = "" ] || [ "$INT_PART" = "0" ]; then + # Try bc first, fall back to awk if unavailable + if command -v bc >/dev/null 2>&1; then + coverage_rate=$(echo "scale=1; $LINE_RATE * 100" | bc 2>/dev/null) + elif command -v awk >/dev/null 2>&1; then + coverage_rate=$(awk "BEGIN {printf \"%.1f\", $LINE_RATE * 100}") + else + # Last resort: assume it's already a percentage + coverage_rate="$LINE_RATE" + fi + else + coverage_rate="$LINE_RATE" + fi + echo "๐Ÿ“Š Extracted coverage: ${coverage_rate}% via $coverage_source" + else + echo "โš ๏ธ Could not extract coverage percentage from file" + echo " Searched for attributes: line-rate, sequenceCoverage" + fi + else + echo "โš ๏ธ grep not available for file analysis" + fi + else + echo "โš ๏ธ No coverage files found for direct analysis" + fi + fi + + echo "" + echo "๐ŸŽฏ VALIDATION RESULTS" + echo "====================" + + # Validate coverage against threshold + threshold_met="false" + if [ -n "$coverage_rate" ]; then + # Convert to integer for comparison (remove decimal part) + coverage_int=$(echo "$coverage_rate" | cut -d'.' -f1) + threshold_int="${{ inputs.threshold }}" + + echo "Coverage: ${coverage_rate}% (source: $coverage_source)" + echo "Threshold: ${threshold_int}%" + echo "" + + if [ "$coverage_int" -ge "$threshold_int" ]; then + threshold_met="true" + echo "โœ… Coverage thresholds met: ${coverage_rate}% โ‰ฅ ${threshold_int}%" + echo " Source: $coverage_source" + else + echo "โŒ Coverage below minimum threshold" + echo " Actual: ${coverage_rate}%" + echo " Required: ${threshold_int}%" + echo " Deficit: $((threshold_int - coverage_int))%" + echo " Source: $coverage_source" + echo "" + echo "๐Ÿ’ก Check the 'Code Coverage Summary' step for detailed information" + + # Only fail in strict mode + if [ "${{ inputs.strict-mode }}" = "true" ]; then + echo "" + echo "๐Ÿšซ STRICT MODE: Failing workflow due to insufficient coverage" + else + echo "" + echo "โš ๏ธ LENIENT MODE: Continuing despite coverage issues" + fi + fi + + # Set outputs once after validation logic completes + echo "coverage-percentage=${coverage_rate}" >> $GITHUB_OUTPUT + echo "coverage-source=${coverage_source}" >> $GITHUB_OUTPUT + echo "threshold-met=${threshold_met}" >> $GITHUB_OUTPUT + + # Exit after outputs are set if in strict mode and threshold not met + if [ "${{ inputs.strict-mode }}" = "true" ] && [ "$threshold_met" != "true" ]; then + exit 1 + fi + else + # No coverage data available + echo "Coverage: Not available" + echo "Threshold: ${{ inputs.threshold }}%" + echo "" + + # Check if coverage files exist even though we can't extract percentage + if [ -d "${{ inputs.coverage-directory }}" ] && \ + [ "$(find "${{ inputs.coverage-directory }}" -name "*.xml" -type f | wc -l)" -gt 0 ]; then + echo "โš ๏ธ Coverage files found but percentage extraction failed" + echo "๐Ÿ” Available coverage files:" + find "${{ inputs.coverage-directory }}" -name "*.xml" -type f | head -5 | sed 's/^/ /' + echo "" + echo "โœ… Assuming coverage is available (files found but analysis failed)" + echo "๐Ÿ’ก Manual review recommended for exact coverage percentage" + + # Set indeterminate outputs + echo "coverage-percentage=unknown" >> $GITHUB_OUTPUT + echo "coverage-source=File Found (Extraction Failed)" >> $GITHUB_OUTPUT + echo "threshold-met=unknown" >> $GITHUB_OUTPUT + else + echo "โŒ Coverage analysis failed - no coverage data available" + echo "" + echo "๐Ÿ’ก This might be due to:" + echo " - Coverage files not generated properly" + echo " - Coverage generation step failed" + echo " - File path mismatch in coverage summary action" + echo " - Test execution failures preventing coverage collection" + echo "" + + # Only fail in strict mode + if [ "${{ inputs.strict-mode }}" = "true" ]; then + echo "๐Ÿšซ STRICT MODE: Failing workflow due to missing coverage data" + echo "coverage-percentage=0" >> $GITHUB_OUTPUT + echo "coverage-source=No Data" >> $GITHUB_OUTPUT + echo "threshold-met=false" >> $GITHUB_OUTPUT + exit 1 + else + echo "โš ๏ธ LENIENT MODE: Continuing despite missing coverage data" + echo "coverage-percentage=0" >> $GITHUB_OUTPUT + echo "coverage-source=No Data" >> $GITHUB_OUTPUT + echo "threshold-met=false" >> $GITHUB_OUTPUT + fi + fi + fi + +branding: + icon: 'check-circle' + color: 'green' diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index c6790ca20..7f43374f6 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -15,67 +15,34 @@ permissions: env: DOTNET_VERSION: '10.0.x' - # Temporary: Allow lenient coverage for this development PR - # TODO: Remove this and improve coverage before merging to main + # CRITICAL: Set STRICT_COVERAGE: true before merging to main + # This enforces 70% minimum coverage threshold (lines 785, 810) + # Current bypass is TEMPORARY for development only + # TODO: Enable STRICT_COVERAGE when overall coverage โ‰ฅ 70% (Sprint 2 milestone) + # Tracking: https://github.com/frigini/MeAjudaAi/issues/33 + # References: docs/testing/code-coverage-guide.md#L297-L313 + # Re-enable when overall coverage reaches 70% (Sprint 2 milestone) STRICT_COVERAGE: false + # PostgreSQL configuration (DRY principle - single source of truth) + # Fallback credentials: Only used in fork/local dev; main repo requires secrets + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} jobs: - # Check if required secrets are configured - check-secrets: - name: Validate Required Secrets - runs-on: ubuntu-latest - outputs: - secrets-available: ${{ steps.check.outputs.available }} - steps: - - name: Check required secrets - id: check - env: - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} - run: | - missing_secrets="" - - # Check each required secret using environment variables - if [ -z "$POSTGRES_PASSWORD" ]; then - missing_secrets="$missing_secrets POSTGRES_PASSWORD" - fi - if [ -z "$POSTGRES_USER" ]; then - missing_secrets="$missing_secrets POSTGRES_USER" - fi - if [ -z "$POSTGRES_DB" ]; then - missing_secrets="$missing_secrets POSTGRES_DB" - fi - - if [ -n "$missing_secrets" ]; then - echo "โŒ Required secrets are missing:$missing_secrets" - echo "" - echo "Please configure the following secrets in your repository:" - for secret in $missing_secrets; do - echo " - $secret" - done - echo "" - echo "๐Ÿ“– Go to Settings โ†’ Secrets and variables โ†’ Actions to add them" - echo "available=false" >> $GITHUB_OUTPUT - exit 1 - else - echo "โœ… All required secrets are configured" - echo "available=true" >> $GITHUB_OUTPUT - fi - # Job 1: Code Quality Checks code-quality: name: Code Quality Checks runs-on: ubuntu-latest - needs: check-secrets services: postgres: image: postgis/postgis:16-3.4 env: - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + # Using workflow-level environment variables (defined at top) + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} POSTGRES_HOST_AUTH_METHOD: md5 options: >- --health-cmd pg_isready @@ -103,8 +70,56 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Check Database Configuration - run: echo "โœ… GitHub secrets configured (enforced by check-secrets)" + - name: Validate Secrets Configuration + env: + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + run: | + echo "Validating secrets configuration..." + + # Check if running in main repository (not a fork) for pull_request or workflow_dispatch + if [ "${{ github.repository }}" = "frigini/MeAjudaAi" ] && \ + ([ "${{ github.event_name }}" = "pull_request" ] || \ + [ "${{ github.event_name }}" = "workflow_dispatch" ]); then + echo "Running in main repository context - strict validation enabled" + + # Validate all required database secrets + HAS_ERROR=0 + + if [ -z "$POSTGRES_PASSWORD" ]; then + echo "ERROR: POSTGRES_PASSWORD secret not configured in main repository" + echo "This secret is required for pull request validation" + HAS_ERROR=1 + else + echo "SUCCESS: POSTGRES_PASSWORD secret configured" + fi + + if [ -z "$POSTGRES_USER" ]; then + echo "ERROR: POSTGRES_USER secret not configured in main repository" + echo "This secret is required for pull request validation" + HAS_ERROR=1 + else + echo "SUCCESS: POSTGRES_USER secret configured" + fi + + if [ -z "$POSTGRES_DB" ]; then + echo "ERROR: POSTGRES_DB secret not configured in main repository" + echo "This secret is required for pull request validation" + HAS_ERROR=1 + else + echo "SUCCESS: POSTGRES_DB secret configured" + fi + + if [ $HAS_ERROR -eq 1 ]; then + echo "Secret validation failed - exiting" + exit 1 + fi + else + echo "Running in fork or non-PR/dispatch event - lenient mode (using test defaults)" + fi + + echo "Database configuration validated" - name: Check Keycloak Configuration env: @@ -133,8 +148,8 @@ jobs: - name: Wait for PostgreSQL to be ready env: - PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + PGPASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ env.POSTGRES_USER }} run: | echo "๐Ÿ”„ Waiting for PostgreSQL to be ready..." echo "Debug: POSTGRES_USER=$POSTGRES_USER" @@ -168,9 +183,9 @@ jobs: with: postgres-host: localhost postgres-port: 5432 - postgres-db: ${{ secrets.POSTGRES_DB }} - postgres-user: ${{ secrets.POSTGRES_USER }} - postgres-password: ${{ secrets.POSTGRES_PASSWORD }} + postgres-db: ${{ env.POSTGRES_DB }} + postgres-user: ${{ env.POSTGRES_USER }} + postgres-password: ${{ env.POSTGRES_PASSWORD }} - name: Run tests with coverage env: @@ -182,15 +197,15 @@ jobs: EXTERNAL_POSTGRES_PORT: 5432 MEAJUDAAI_DB_HOST: localhost MEAJUDAAI_DB_PORT: 5432 - MEAJUDAAI_DB_PASS: ${{ secrets.POSTGRES_PASSWORD }} - MEAJUDAAI_DB_USER: ${{ secrets.POSTGRES_USER }} - MEAJUDAAI_DB: ${{ secrets.POSTGRES_DB }} + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} # Legacy environment variables for compatibility DB_HOST: localhost DB_PORT: 5432 - DB_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - DB_USERNAME: ${{ secrets.POSTGRES_USER }} - DB_NAME: ${{ secrets.POSTGRES_DB }} + DB_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + DB_USERNAME: ${{ env.POSTGRES_USER }} + DB_NAME: ${{ env.POSTGRES_DB }} # Keycloak settings KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} # Azure Storage (Azurite emulator) @@ -251,75 +266,140 @@ jobs: echo "๐Ÿงช Running unit tests with coverage for all modules..." # Define modules for coverage testing - # FORMAT: "ModuleName:path/to/module/tests/" - # TO ADD NEW MODULE: Add line like "Orders:src/Modules/Orders/Tests/" - # See docs/adding-new-modules.md for complete instructions + # STRATEGY: Comprehensive coverage for Domain, Application, and critical Shared/ApiService layers + # - Domain/Application: Core business logic (80-100% target) + # - Shared/ApiService: Reusable infrastructure, middlewares, extensions (60-80% target) + # - Excluded: Integration tests, E2E tests, Architecture tests (tested separately below) + # + # RATIONALE FOR INFRASTRUCTURE EXCLUSION: + # Infrastructure tests are excluded from module-based coverage runs (--filter) because: + # 1. Infrastructure tests often require real dependencies (databases, message brokers, etc.) + # 2. Mixing unit tests (mocked) with infrastructure tests distorts coverage metrics + # 3. Infrastructure/Integration tests run separately (lines 345-366) with proper test environment + # 4. This separation ensures unit test coverage accurately reflects business logic coverage + # + # FORMAT: "ModuleName:path/to/module/tests/:IncludeFilter" MODULES=( - "Users:src/Modules/Users/Tests/" - # Future modules can be added here: - # "Orders:src/Modules/Orders/Tests/" - # "Payments:src/Modules/Payments/Tests/" + # Domain module tests + "Users:src/Modules/Users/Tests/:MeAjudaAi.Modules.Users.*" + "Providers:src/Modules/Providers/Tests/:MeAjudaAi.Modules.Providers.*" + "Documents:src/Modules/Documents/Tests/:MeAjudaAi.Modules.Documents.*" + "ServiceCatalogs:src/Modules/ServiceCatalogs/Tests/:MeAjudaAi.Modules.ServiceCatalogs.*" + "Locations:src/Modules/Locations/Tests/:MeAjudaAi.Modules.Locations.*" + "SearchProviders:src/Modules/SearchProviders/Tests/:MeAjudaAi.Modules.SearchProviders.*" + + # System/Shared tests (626+ tests - HIGH PRIORITY) + "Shared:tests/MeAjudaAi.Shared.Tests/:MeAjudaAi.Shared" + "ApiService:tests/MeAjudaAi.ApiService.Tests/:MeAjudaAi.ApiService" ) # Run unit tests for each module with coverage for module_info in "${MODULES[@]}"; do - IFS=':' read -r module_name module_path <<< "$module_info" + IFS=':' read -r module_name module_path include_pattern <<< "$module_info" if [ -d "$module_path" ]; then - echo "Running $module_name module unit tests with coverage..." + echo "Running $module_name tests with coverage..." # Create specific output directory for this module MODULE_COVERAGE_DIR="./coverage/${module_name,,}" mkdir -p "$MODULE_COVERAGE_DIR" - # Run tests with simplified coverage collection - UNIT TESTS ONLY - INCLUDE_FILTER="[MeAjudaAi.Modules.${module_name}.*]*" - EXCLUDE_FILTER="[*.Tests]*,[*Test*]*,[testhost]*" - dotnet test "$module_path" \ + # Dynamic include filter based on module type + if [ -n "$include_pattern" ]; then + INCLUDE_FILTER="[${include_pattern}]*" + else + INCLUDE_FILTER="[MeAjudaAi.*]*" + fi + + # Validate filter is not empty or trivial + if [ "$INCLUDE_FILTER" = "[]*" ] || [ "$INCLUDE_FILTER" = "[].*" ]; then + echo "โš ๏ธ INCLUDE_FILTER is trivial, falling back to [MeAjudaAi.*]*" + INCLUDE_FILTER="[MeAjudaAi.*]*" + fi + + # NOTE: EXCLUDE_FILTER is currently hardcoded for all modules. + # Future enhancement: Consider adding per-module exclusion patterns + # as part of the MODULES array definition (e.g., "Module:path/:Include:Exclude") + # to support module-specific test category or infrastructure exclusions. + EXCLUDE_FILTER="[*.Tests*]*,[*Test*]*,[testhost]*" + + echo " Include: $INCLUDE_FILTER" + echo " Exclude: $EXCLUDE_FILTER" + + # Create temporary runsettings file for this module + cat > "/tmp/${module_name,,}.runsettings" < + + + + + + opencover + ${INCLUDE_FILTER} + ${EXCLUDE_FILTER} + + + + + +EOF + + dotnet test "$module_path" \ --configuration Release \ - --no-build \ --verbosity normal \ --filter "FullyQualifiedName!~Integration&FullyQualifiedName!~Infrastructure" \ --collect:"XPlat Code Coverage" \ --results-directory "$MODULE_COVERAGE_DIR" \ --logger "trx;LogFileName=${module_name,,}-test-results.trx" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="$INCLUDE_FILTER" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="$EXCLUDE_FILTER" + --settings "/tmp/${module_name,,}.runsettings" # Find and rename the coverage file to a predictable name if [ -d "$MODULE_COVERAGE_DIR" ]; then - # Look for both opencover and cobertura formats - COVERAGE_FILE=$(find "$MODULE_COVERAGE_DIR" \ - -name "coverage.opencover.xml" -o -name "coverage.cobertura.xml" \ - -type f | head -1) + echo "๐Ÿ” Searching for coverage files in $MODULE_COVERAGE_DIR..." + echo "๐Ÿ“‚ Directory contents:" + find "$MODULE_COVERAGE_DIR" -type f -name "*.xml" | head -10 + + # Look for both opencover and cobertura formats (search recursively in GUID subdirs) + COVERAGE_FILE=$(find "$MODULE_COVERAGE_DIR" -type f \ + \( -name "coverage.opencover.xml" -o -name "coverage.cobertura.xml" \) \ + -print -quit) if [ -f "$COVERAGE_FILE" ]; then + echo "โœ… Found coverage file: $COVERAGE_FILE" # Copy to standardized name based on original format if [[ "$COVERAGE_FILE" == *"cobertura"* ]]; then cp "$COVERAGE_FILE" "$MODULE_COVERAGE_DIR/${module_name,,}.cobertura.xml" - echo "โœ… Cobertura coverage file created: $MODULE_COVERAGE_DIR/${module_name,,}.cobertura.xml" else cp "$COVERAGE_FILE" "$MODULE_COVERAGE_DIR/${module_name,,}.opencover.xml" - echo "โœ… OpenCover coverage file created: $MODULE_COVERAGE_DIR/${module_name,,}.opencover.xml" fi else - echo "โš ๏ธ Coverage file not found for $module_name module" - echo "Available files in coverage directory:" + echo "โš ๏ธ Coverage file not found for $module_name" + echo "๐Ÿ“‹ Available XML files:" find "$MODULE_COVERAGE_DIR" -name "*.xml" -type f | head -5 fi + else + echo "โŒ Coverage directory not found: $MODULE_COVERAGE_DIR" fi else - echo "โš ๏ธ Module $module_name tests not found at $module_path - skipping" + echo "โš ๏ธ $module_name tests not found at $module_path - skipping" fi done + echo "" + echo "๐Ÿ“Š COVERAGE FILES SUMMARY" + echo "=========================" + echo "Total coverage XML files generated:" + find ./coverage -type f \( -name "*.opencover.xml" -o -name "*.cobertura.xml" \) | wc -l + echo "" + echo "Coverage files by module:" + find ./coverage -type f \( -name "*.opencover.xml" -o -name "*.cobertura.xml" \) -printf "%f\n" | sort | head -20 + echo "" + echo "๐Ÿงช Running system tests without coverage collection..." - # Define system tests (no coverage) + # Define system tests (no coverage) - architectural and integration validation only SYSTEM_TESTS=( "Architecture:tests/MeAjudaAi.Architecture.Tests/" "Integration:tests/MeAjudaAi.Integration.Tests/" - "Shared:tests/MeAjudaAi.Shared.Tests/" "E2E:tests/MeAjudaAi.E2E.Tests/" ) @@ -348,9 +428,9 @@ jobs: EXTERNAL_POSTGRES_PORT: 5432 MEAJUDAAI_DB_HOST: localhost MEAJUDAAI_DB_PORT: 5432 - MEAJUDAAI_DB_PASS: ${{ secrets.POSTGRES_PASSWORD }} - MEAJUDAAI_DB_USER: ${{ secrets.POSTGRES_USER }} - MEAJUDAAI_DB: ${{ secrets.POSTGRES_DB }} + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} ConnectionStrings__HangfireConnection: ${{ steps.db.outputs.connection-string }} run: | @@ -472,8 +552,9 @@ jobs: done # Find coverage files in nested directories and copy to module directories - find ./coverage -name "coverage.opencover.xml" \ - -o -name "coverage.cobertura.xml" -type f | while read -r file; do + find ./coverage -type f \ + \( -name "coverage.opencover.xml" -o -name "coverage.cobertura.xml" \) \ + | while read -r file; do # Get the module directory (should be like ./coverage/users/) module_dir=$(echo "$file" | sed 's|coverage/\([^/]*\)/.*|coverage/\1|') module_name=$(basename "$module_dir") @@ -705,143 +786,21 @@ jobs: *This comment is updated automatically on each push to track coverage trends.* + # Refactored: Extracted to reusable action for better maintainability + # See: .github/actions/validate-coverage/README.md for documentation - name: Validate Coverage Thresholds if: always() - run: | - echo "๐ŸŽฏ VALIDATING COVERAGE THRESHOLDS" - echo "=================================" - - # Check step outcomes first - primary_success="${{ steps.coverage_opencover.outcome }}" - cobertura_success="${{ steps.coverage_cobertura.outcome }}" - fallback_success="${{ steps.coverage_fallback.outcome }}" - - echo "Debug: Primary (OpenCover) outcome: ${primary_success:-'not_run'}" - echo "Debug: Cobertura coverage outcome: ${cobertura_success:-'not_run'}" - echo "Debug: Fallback coverage outcome: ${fallback_success:-'not_run'}" - - # Get coverage percentages (if available) - primary_line_rate="${{ steps.coverage_opencover.outputs.line-rate }}" - cobertura_line_rate="${{ steps.coverage_cobertura.outputs.line-rate }}" - fallback_line_rate="${{ steps.coverage_fallback.outputs.line-rate }}" - - echo "Debug: Primary line rate: ${primary_line_rate:-'not_available'}" - echo "Debug: Cobertura line rate: ${cobertura_line_rate:-'not_available'}" - echo "Debug: Fallback line rate: ${fallback_line_rate:-'not_available'}" - - # Check if coverage files exist for debugging - echo "Debug: Checking coverage files..." - find coverage -name "*.xml" -type f 2>/dev/null || echo "No coverage XML files found" - - # Try to use step outputs first, then fall back to direct file analysis - coverage_rate="" - coverage_source="" - - if [ "$primary_success" = "success" ] && [ -n "$primary_line_rate" ]; then - coverage_rate="$primary_line_rate" - coverage_source="OpenCover" - echo "๐Ÿ“Š Using primary (OpenCover) coverage: ${coverage_rate}%" - elif [ "$cobertura_success" = "success" ] && [ -n "$cobertura_line_rate" ]; then - coverage_rate="$cobertura_line_rate" - coverage_source="Cobertura" - echo "๐Ÿ“Š Using Cobertura coverage: ${coverage_rate}%" - elif [ "$fallback_success" = "success" ] && [ -n "$fallback_line_rate" ]; then - coverage_rate="$fallback_line_rate" - coverage_source="Fallback XML" - echo "๐Ÿ“Š Using fallback coverage: ${coverage_rate}%" - else - # Direct file analysis when steps fail but files exist - echo "๐Ÿ” Step outputs unavailable, analyzing coverage files directly..." - - COVERAGE_FILE="" - if find coverage -name "*.opencover.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.opencover.xml" -type f | head -1) - coverage_source="OpenCover (Direct Analysis)" - elif find coverage -name "*.cobertura.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.cobertura.xml" -type f | head -1) - coverage_source="Cobertura (Direct Analysis)" - elif find coverage -name "*.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.xml" -type f | head -1) - coverage_source="XML (Direct Analysis)" - fi - - if [ -n "$COVERAGE_FILE" ] && [ -f "$COVERAGE_FILE" ]; then - echo "๐Ÿ“ Analyzing coverage file: $COVERAGE_FILE" - - # Try to extract coverage percentage - if command -v grep >/dev/null 2>&1; then - # Look for line-rate or sequenceCoverage attributes - LINE_RATE=$(grep -o 'line-rate="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) - if [ -z "$LINE_RATE" ]; then - LINE_RATE=$(grep -o 'sequenceCoverage="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) - fi - - if [ -n "$LINE_RATE" ]; then - # Convert decimal to percentage if needed (0.xx to xx%) - if [ "$(echo "$LINE_RATE" | cut -d'.' -f1)" = "0" ]; then - coverage_rate=$(echo "$LINE_RATE * 100" | bc -l 2>/dev/null || \ - echo "scale=1; $LINE_RATE * 100" | bc 2>/dev/null || \ - echo "$LINE_RATE") - else - coverage_rate="$LINE_RATE" - fi - echo "๐Ÿ“Š Extracted coverage: ${coverage_rate}% via $coverage_source" - else - echo "โš ๏ธ Could not extract coverage percentage from file" - fi - else - echo "โš ๏ธ grep not available for file analysis" - fi - fi - fi - - # Validate coverage against threshold - if [ -n "$coverage_rate" ]; then - # Convert to integer for comparison (remove decimal part) - coverage_int=$(echo "$coverage_rate" | cut -d'.' -f1) - - if [ "$coverage_int" -ge 70 ]; then - echo "โœ… Coverage analysis completed successfully" - echo "๐Ÿ“Š Coverage thresholds met: ${coverage_rate}% (โ‰ฅ70%) via $coverage_source" - else - echo "โŒ Coverage below minimum threshold: ${coverage_rate}% (required: โ‰ฅ70%) via $coverage_source" - echo "๐Ÿ’ก Check the 'Code Coverage Summary' step for detailed information" - - # Only fail in strict mode - if [ "${STRICT_COVERAGE:-true}" = "true" ]; then - echo "๐Ÿšซ STRICT MODE: Failing pipeline due to insufficient coverage" - exit 1 - else - echo "โš ๏ธ LENIENT MODE: Continuing despite coverage issues" - fi - fi - else - # Check if coverage files exist even though we can't extract percentage - if find coverage -name "*.xml" -type f | head -1 >/dev/null 2>&1; then - echo "โš ๏ธ Coverage files found but percentage extraction failed" - echo "๐Ÿ” Available coverage files:" - find coverage -name "*.xml" -type f | head -5 - - # In this case, assume coverage is available and continue - echo "โœ… Assuming coverage is available (files found but analysis failed)" - echo "๐Ÿ’ก Manual review recommended for exact coverage percentage" - else - echo "โŒ Coverage analysis failed - no coverage data available" - echo "๐Ÿ’ก This might be due to:" - echo " - Coverage files not generated properly" - echo " - Coverage generation step failed" - echo " - File path mismatch in coverage summary action" - - # Only fail in strict mode - if [ "${STRICT_COVERAGE:-true}" = "true" ]; then - echo "๐Ÿšซ STRICT MODE: Failing pipeline due to coverage analysis failure" - echo "๐Ÿ’ก To continue despite coverage issues, set STRICT_COVERAGE=false" - exit 1 - else - echo "โš ๏ธ LENIENT MODE: Continuing despite coverage analysis issues" - fi - fi - fi + uses: ./.github/actions/validate-coverage + with: + coverage-directory: './coverage' + threshold: '70' + strict-mode: ${{ env.STRICT_COVERAGE }} + opencover-output: ${{ steps.coverage_opencover.outputs.line-rate }} + opencover-outcome: ${{ steps.coverage_opencover.outcome }} + cobertura-output: ${{ steps.coverage_cobertura.outputs.line-rate }} + cobertura-outcome: ${{ steps.coverage_cobertura.outcome }} + fallback-output: ${{ steps.coverage_fallback.outputs.line-rate }} + fallback-outcome: ${{ steps.coverage_fallback.outcome }} # Job 2: Security Scan (Consolidated) security-scan: @@ -874,8 +833,44 @@ jobs: # Install OSV-Scanner with pinned version for reproducibility OSV_VERSION="v1.8.3" OSV_URL="https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}" - curl -sSfL "${OSV_URL}/osv-scanner_linux_amd64" -o osv-scanner - chmod +x osv-scanner + + # Retry logic for network transient failures + max_retries=3 + retry_count=0 + + while [ $retry_count -lt $max_retries ]; do + if curl -sSfL --connect-timeout 10 --max-time 30 \ + "${OSV_URL}/osv-scanner_linux_amd64" -o osv-scanner; then + echo "โœ… Download successful" + chmod +x osv-scanner + break + else + retry_count=$((retry_count + 1)) + if [ $retry_count -lt $max_retries ]; then + echo "โš ๏ธ Download failed (attempt $retry_count/$max_retries), retrying in 5s..." + sleep 5 + else + echo "โŒ Download failed after $max_retries attempts" + echo "๐Ÿ’ก Trying fallback: install via Go toolchain..." + + # Fallback: Install via Go if available + if command -v go >/dev/null 2>&1; then + echo "โ„น๏ธ Using Go to install OSV-Scanner..." + go install github.com/google/osv-scanner/cmd/osv-scanner@${OSV_VERSION} + # Copy to local dir for consistent usage + cp "$(go env GOPATH)/bin/osv-scanner" ./osv-scanner 2>/dev/null || \ + ln -s "$(go env GOPATH)/bin/osv-scanner" ./osv-scanner + else + echo "โŒ Go toolchain not available, cannot install OSV-Scanner" + echo "โš ๏ธ CRITICAL: Vulnerability scan cannot be performed" + echo "๐Ÿ’ก MITIGATION: Ensure runner has Go โ‰ฅ1.18 installed or provide pre-built OSV-Scanner binary" + echo "๐Ÿ’ก SECURITY NOTE: This workflow MUST fail when vulnerability scanning is unavailable." + echo " To fix: ensure Go is installed on the runner or configure a pre-built OSV-Scanner binary." + exit 1 # Fail the workflow - security scanning is mandatory + fi + fi + fi + done echo "OSV-Scanner version and help:" ./osv-scanner --version || echo "Version command failed"