Coverage Summary #436
Workflow file for this run
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: Coverage Summary | |
| on: | |
| merge_group: | |
| pull_request: | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: coverage-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # --------------------------------------------------------------------- | |
| # Path detection: determine whether this PR touches coverage-relevant | |
| # files. When it doesn't (e.g. docs-only or examples-only PRs) we skip | |
| # the expensive per-surface jobs but still report the required | |
| # coverage-summary check as passed. | |
| # --------------------------------------------------------------------- | |
| changes: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_coverage_files: ${{ steps.filter.outputs.coverage }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| coverage: | |
| - 'control-plane/**' | |
| - 'sdk/**' | |
| - 'scripts/test-all.sh' | |
| - 'scripts/coverage-summary.sh' | |
| - 'scripts/coverage-surface.sh' | |
| - 'scripts/coverage-aggregate.py' | |
| - 'scripts/coverage-gate.py' | |
| - 'scripts/patch-coverage-gate.sh' | |
| - '.coverage-gate.toml' | |
| - 'coverage-baseline.json' | |
| - '.github/workflows/coverage.yml' | |
| - 'docs/COVERAGE.md' | |
| - 'docs/DEVELOPMENT.md' | |
| - '.github/BRANCH_PROTECTION.md' | |
| # --------------------------------------------------------------------- | |
| # Per-surface coverage producers (run in parallel). Each entry runs the | |
| # test suite for a single surface with coverage enabled via | |
| # scripts/coverage-surface.sh, then uploads test-reports/coverage/ as an | |
| # artifact named coverage-<surface>. The aggregator job downloads all | |
| # five and computes the final summary + gate. | |
| # | |
| # This is the "Option B" pattern: coverage runs live here and nowhere | |
| # else. control-plane.yml and sdk-go.yml deliberately no longer run | |
| # `go test` — they keep build + lint only, and the coverage workflow is | |
| # a required status check so a test regression still blocks merge. | |
| # --------------------------------------------------------------------- | |
| per-surface: | |
| needs: changes | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| name: coverage (${{ matrix.surface }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| surface: | |
| - control-plane | |
| - sdk-go | |
| - sdk-python | |
| - sdk-typescript | |
| - web-ui | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Go | |
| if: matrix.surface == 'control-plane' || matrix.surface == 'sdk-go' | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.25.x" | |
| - name: Set up Python | |
| if: matrix.surface == 'sdk-python' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: pip | |
| cache-dependency-path: sdk/python/pyproject.toml | |
| - name: Set up Node.js | |
| if: matrix.surface == 'control-plane' || matrix.surface == 'sdk-typescript' || matrix.surface == 'web-ui' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| cache: npm | |
| cache-dependency-path: | | |
| control-plane/web/client/package-lock.json | |
| sdk/typescript/package-lock.json | |
| - name: Run ${{ matrix.surface }} coverage | |
| run: ./scripts/coverage-surface.sh ${{ matrix.surface }} | |
| - name: Upload ${{ matrix.surface }} coverage artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-${{ matrix.surface }} | |
| path: test-reports/coverage/ | |
| retention-days: 1 | |
| if-no-files-found: error | |
| # --------------------------------------------------------------------- | |
| # Aggregator. Waits for all per-surface jobs, downloads their artifacts, | |
| # flattens them into test-reports/coverage/, then runs the aggregation | |
| # + coverage gate + patch-coverage gate + sticky PR comments + badge | |
| # gist update. | |
| # | |
| # IMPORTANT: The GitHub check produced by this job is named | |
| # `coverage-summary` — matching the old single-job workflow — so any | |
| # existing branch protection rule targeting that check name keeps | |
| # working without changes. | |
| # --------------------------------------------------------------------- | |
| coverage-summary: | |
| name: coverage-summary | |
| # Use always() so this job runs even when per-surface is skipped | |
| # (merge_group or no coverage-relevant files). This ensures the | |
| # required check always reports a status instead of being stuck as | |
| # "Expected". | |
| if: ${{ always() }} | |
| needs: [changes, per-surface] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| # --------------------------------------------------------------- | |
| # Fast path: skip heavy coverage work when it's not needed — either | |
| # in the merge queue (coverage already passed on the PR) or when the | |
| # PR doesn't touch any coverage-relevant files (e.g. docs-only, | |
| # examples-only). The job still succeeds so the required check is | |
| # satisfied. | |
| # --------------------------------------------------------------- | |
| - name: Skip coverage (no relevant changes) | |
| if: github.event_name == 'merge_group' || needs.changes.outputs.has_coverage_files != 'true' | |
| run: echo "Coverage check skipped — no coverage-relevant files changed or merge queue." | |
| - name: Checkout | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| # Full history so diff-cover can compute patch coverage against | |
| # origin/main. | |
| fetch-depth: 0 | |
| - name: Set up Python | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install diff-cover (for per-PR patch coverage) | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: python -m pip install "diff-cover>=9.0.0" | |
| - name: Download all per-surface artifacts | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: /tmp/coverage-artifacts | |
| pattern: coverage-* | |
| - name: Stage artifacts into test-reports/coverage/ | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: | | |
| mkdir -p test-reports/coverage | |
| # Each per-surface artifact lands in its own subdirectory under | |
| # /tmp/coverage-artifacts/ (coverage-control-plane/, | |
| # coverage-sdk-go/, ...). Flatten them all into the single | |
| # expected location. | |
| find /tmp/coverage-artifacts -type f -exec cp -v {} test-reports/coverage/ \; | |
| echo "--- staged files ---" | |
| ls -la test-reports/coverage/ | |
| - name: Aggregate per-surface coverage | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: python3 scripts/coverage-aggregate.py --report-dir test-reports/coverage | |
| - name: Publish coverage summary | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: cat test-reports/coverage/summary.md >> "$GITHUB_STEP_SUMMARY" | |
| - name: Run coverage gate | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| id: gate | |
| # Thresholds live in .coverage-gate.toml at the repo root so they | |
| # stay in-version-control and are the single source of truth for | |
| # both local runs and CI. | |
| run: | | |
| ./scripts/coverage-gate.py \ | |
| --summary test-reports/coverage/summary.json \ | |
| --baseline coverage-baseline.json \ | |
| --config .coverage-gate.toml | |
| continue-on-error: true | |
| - name: Run patch coverage gate (diff-cover) | |
| id: patch_gate | |
| # Enforces .coverage-gate.toml:thresholds.min_patch on lines the PR | |
| # actually touches, against origin/main. This is the single most | |
| # effective regression signal — aggregates drift slowly, but new | |
| # untested code shows up here immediately. Output is posted as a | |
| # second sticky PR comment so reviewers see exactly which files | |
| # regressed patch coverage. | |
| if: github.event_name == 'pull_request' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: | | |
| ./scripts/patch-coverage-gate.sh | |
| continue-on-error: true | |
| - name: Save PR metadata for report workflow | |
| if: github.event_name == 'pull_request' && needs.changes.outputs.has_coverage_files == 'true' | |
| run: echo '${{ github.event.pull_request.number }}' > test-reports/coverage/pr-number.txt | |
| - name: Upload aggregated coverage artifact | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-summary | |
| path: test-reports/coverage/ | |
| # Retention is long enough to cover the gap between a PR's last | |
| # coverage run and the eventual merge to main, so coverage-badge.yml | |
| # can still find the artifact when it runs post-merge. | |
| retention-days: 30 | |
| - name: Fail the job if any gate failed | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.has_coverage_files == 'true' && (steps.gate.outcome == 'failure' || steps.patch_gate.outcome == 'failure') | |
| run: | | |
| echo "::error::Coverage gate failed. See the coverage report comment(s) on the PR for details and remediation steps." | |
| exit 1 | |
| # Badge gist updates on merge to main are handled by coverage-badge.yml, | |
| # which reuses this workflow's coverage-summary artifact rather than | |
| # recomputing coverage on every push to main. |