diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 79e30526..4278153e 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -2,10 +2,6 @@ on: push: branches: - '**' - pull_request: - branches: - - '**' - types: [ opened, reopened, synchronize ] workflow_call: inputs: platforms: @@ -34,20 +30,13 @@ name: ci-build env: REGISTRY: ghcr.io DOTNET_VERSION: 10.0.x + COVERAGE_MIN_PERCENT: '85' jobs: tests: - name: Tests (${{ matrix.name }}) + name: Tests runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - name: common-unit - project: Common.Tests/Common.Tests.csproj - - name: api-integration - project: API.IntegrationTests/API.IntegrationTests.csproj steps: - name: Checkout @@ -57,10 +46,130 @@ jobs: with: dotnet-version: '${{ env.DOTNET_VERSION }}' - - name: Run ${{ matrix.name }} tests + - name: Run tests + run: | + set -euo pipefail + dotnet test -c Release --solution OpenShockBackend.slnx \ + --results-directory "artifacts/test-results" \ + -- \ + --coverage \ + --coverage-output coverage.cobertura.xml \ + --coverage-output-format cobertura \ + --report-trx \ + --report-trx-filename test-results.trx + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: artifacts/test-results + if-no-files-found: warn + + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: [tests] + outputs: + linecoverage: ${{ steps.enforce.outputs.linecoverage }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '${{ env.DOTNET_VERSION }}' + + - name: Download test artifacts + uses: actions/download-artifact@v5 + with: + name: test-results + path: artifacts/test-results + + - name: Install ReportGenerator + run: dotnet tool install --tool-path ./.tools dotnet-reportgenerator-globaltool + + - name: Generate merged coverage report run: | set -euo pipefail - dotnet test -c Release --project ${{ matrix.project }} + reports="$(find artifacts/test-results -name coverage.cobertura.xml -print | paste -sd ';' -)" + if [ -z "$reports" ]; then + echo "No coverage reports found" >&2 + exit 1 + fi + + ./.tools/reportgenerator \ + "-reports:${reports}" \ + "-targetdir:artifacts/coverage" \ + "-reporttypes:Html;MarkdownSummaryGithub;JsonSummary;Badges" + + - name: Add coverage summary + run: cat artifacts/coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" + + - name: Enforce minimum coverage + id: enforce + env: + COVERAGE_MIN_PERCENT: ${{ env.COVERAGE_MIN_PERCENT }} + run: | + python - <<'PY' + import json + import os + + threshold = float(os.environ["COVERAGE_MIN_PERCENT"]) + with open("artifacts/coverage/Summary.json", "r", encoding="utf-8") as handle: + summary = json.load(handle)["summary"] + + line_coverage = float(summary["linecoverage"]) + print(f"Line coverage: {line_coverage:.1f}%") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"linecoverage={line_coverage:.1f}\n") + + if line_coverage < threshold: + raise SystemExit( + f"Coverage threshold not met: {line_coverage:.1f}% < {threshold:.1f}%" + ) + PY + + - name: Upload merged coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: artifacts/coverage + if-no-files-found: error + + publish-coverage: + name: Publish coverage + runs-on: ubuntu-latest + needs: coverage + if: ${{ github.ref_type == 'branch' && github.event_name != 'pull_request' && github.ref_name == 'master' }} + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Download merged coverage report + uses: actions/download-artifact@v5 + with: + name: coverage-report + path: _site/coverage + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: _site + + - name: Deploy Pages site + id: deployment + uses: actions/deploy-pages@v4 build: name: Build (${{ matrix.image }}) @@ -101,7 +210,7 @@ jobs: promote-image: name: Promote Image (${{ matrix.image }}) - needs: [build, tests] + needs: [build, coverage] runs-on: ubuntu-latest if: ${{ inputs.push || (github.ref_protected && github.event_name != 'pull_request') }} strategy: @@ -180,4 +289,4 @@ jobs: - uses: ./.github/actions/watchtower-update with: url: ${{ vars.WATCHTOWER_URL }} - token: ${{ secrets.WATCHTOWER_TOKEN }} \ No newline at end of file + token: ${{ secrets.WATCHTOWER_TOKEN }} diff --git a/API.IntegrationTests/AssemblyAttributes.cs b/API.IntegrationTests/AssemblyAttributes.cs index d7d85793..4cca6adb 100644 --- a/API.IntegrationTests/AssemblyAttributes.cs +++ b/API.IntegrationTests/AssemblyAttributes.cs @@ -1,10 +1,10 @@ using TUnit.Core; using TUnit.Core.Interfaces; -// Allow up to 3 minutes per test — integration tests can be slow in CI when Docker images +// Allow up to 5 minutes per test — integration tests can be slow in CI when Docker images // are cold-pulled and EF migrations run for the first time. The execution timer in TUnit // may include class-data-source initialization time for the first test that uses the factory. -[assembly: Timeout(3 * 60_000)] +[assembly: Timeout(5 * 60_000)] // Limit parallel test execution to avoid thread pool starvation on CI runners. // BCrypt password hashing in login/signup endpoints is synchronous and CPU-bound; diff --git a/README.md b/README.md index a28f262d..c7d67316 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # OpenShock API +[![Coverage](https://openshock.github.io/API/coverage/badge_linecoverage.svg)](https://openshock.github.io/API/coverage/) + OpenShock backend ### API Documentation