diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 76262d70..ac795c25 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -109,10 +109,15 @@ jobs:
test-with-coverage:
runs-on: ubuntu-latest
+ outputs:
+ diff-coverage: ${{ steps.diff_cover.outputs.percent }}
steps:
- name: Checkout code
uses: https://github.com/actions/checkout@v5
+ with:
+ # diff-cover needs the base branch present to diff against
+ fetch-depth: 0
- name: Setup
uses: ./.gitea/actions/install
@@ -129,29 +134,84 @@ jobs:
source .venv/bin/activate
pytest --cov=aare --cov-config=./pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./tests/unit
- - name: Build coverage summary
+ # No --fail-under here: the gate lives in coverage-analysis so that the PR
+ # comment is posted before the build is failed.
+ - name: Diff coverage
+ id: diff_cover
+ env:
+ BASE_BRANCH: ${{ github.base_ref || 'main' }}
run: |
source .venv/bin/activate
+ diff-cover coverage.xml \
+ --compare-branch="origin/${BASE_BRANCH}" \
+ --format "markdown:diff-cover.md,json:diff-cover.json"
+ percent=$(python -c "import json; print(json.load(open('diff-cover.json'))['total_percent_covered'])")
+ echo "percent=${percent}" >> "$GITHUB_OUTPUT"
+ echo "Diff coverage: ${percent}%"
+
+ # Two artefacts on purpose: the full report is too big to post. The F5 WAF
+ # in front of gitea.psi.ch rejects large POST bodies with an HTML 403, so
+ # the comment body is capped and the detail lives in the artifact.
+ - name: Build coverage summary
+ env:
+ BASE_BRANCH: ${{ github.base_ref || 'main' }}
+ DIFF_COVERAGE: ${{ steps.diff_cover.outputs.percent }}
+ COMMENT_DIFF_BUDGET: "6000"
+ run: |
+ source .venv/bin/activate
+ total=$(coverage report --format=total)
+
{
- echo ""
- echo "### Coverage report"
+ echo "# Coverage report"
echo
- echo "**Total line + branch coverage: $(coverage report --format=total)%**"
+ echo "| Metric | Value |"
+ echo "| --- | --- |"
+ echo "| Total line + branch coverage | ${total}% |"
+ echo "| Diff coverage vs \`${BASE_BRANCH}\` | ${DIFF_COVERAGE}% (minimum 80%) |"
echo
- echo "Per-file breakdown
"
+ cat diff-cover.md
+ echo
+ echo "## Per-file breakdown"
echo
coverage report --format=markdown
- echo
- echo " "
} > coverage-summary.md
+ # Kept free of markup on purpose. The F5 WAF in front of gitea.psi.ch
+ # rejects the comment POST with an HTML 403, and angle-bracket tags in
+ # a request body are a stock XSS signature -- so no , no
+ # marker, and diff-cover's . escapes are decoded back to
+ # plain dots. Backticks are dropped too (SQLi signatures match them).
+ sed 's/././g' diff-cover.md > diff-cover-plain.md
+ {
+ echo "Coverage report (automated)"
+ echo
+ echo "| Metric | Value |"
+ echo "| --- | --- |"
+ echo "| Total line + branch coverage | ${total}% |"
+ echo "| Diff coverage vs ${BASE_BRANCH} | ${DIFF_COVERAGE}% (minimum 80%) |"
+ echo
+ echo "Uncovered lines in this diff:"
+ echo
+ if [ "$(wc -c < diff-cover-plain.md)" -gt "${COMMENT_DIFF_BUDGET}" ]; then
+ head -c "${COMMENT_DIFF_BUDGET}" diff-cover-plain.md
+ echo
+ echo "(truncated)"
+ else
+ cat diff-cover-plain.md
+ fi
+ echo
+ echo "Full per-file breakdown: coverage artifact on this run."
+ } > coverage-comment.md
+
# Only the rendered summary is uploaded. coverage.xml (~1 MB) is rejected
# with an HTML 403 by the proxy in front of gitea.psi.ch.
- name: Upload coverage
uses: https://github.com/actions/upload-artifact@v3
with:
name: coverage
- path: coverage-summary.md
+ path: |
+ coverage-summary.md
+ coverage-comment.md
if-no-files-found: error
coverage-analysis:
@@ -172,13 +232,28 @@ jobs:
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
- comment-author: "github-actions[bot]"
- body-includes: ""
+ body-includes: "Coverage report (automated)"
- name: Create or update comment
uses: https://github.com/peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
- body-path: coverage-summary.md
+ body-path: coverage-comment.md
edit-mode: replace
+
+ # Last, so the report is always visible on the PR even when this fails.
+ - name: Enforce diff coverage
+ env:
+ DIFF_COVERAGE: ${{ needs.test-with-coverage.outputs.diff-coverage }}
+ MINIMUM: "80"
+ run: |
+ if [ -z "${DIFF_COVERAGE}" ]; then
+ echo "::error::test-with-coverage produced no diff coverage value"
+ exit 1
+ fi
+ echo "Diff coverage: ${DIFF_COVERAGE}% (minimum ${MINIMUM}%)"
+ if awk -v p="${DIFF_COVERAGE}" -v m="${MINIMUM}" 'BEGIN { exit !(p + 0 < m + 0) }'; then
+ echo "::error::Diff coverage ${DIFF_COVERAGE}% is below the required ${MINIMUM}%"
+ exit 1
+ fi
diff --git a/pyproject.toml b/pyproject.toml
index 005125bc..3408b544 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ test = [
"pytest-qt",
"pytest-asyncio",
"pytest-timeout",
+ "diff-cover",
"ruff>=0.15"
]