A GitHub Action that wraps diff-cover to report test coverage and code quality only on changed lines. Native GitHub integration with PR comments, inline annotations, step summaries, and badge generation.
Zero vendor lock-in -- everything runs locally in the GitHub Actions runner. No external services, no accounts, no data leaving your CI.
Drop this file into .github/workflows/diff-coverage.yml, push, and open a PR. A coverage comment will appear automatically — no signup, no token, no external service.
name: Diff Coverage
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pytest pytest-cov && pytest --cov --cov-report=xml
- uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xmlThat's the whole setup. It works on any language that produces Cobertura XML, lcov, or JaCoCo — replace the pytest line with your own test command (Jest, go test -coverprofile, Maven, etc.). For thresholds, monorepos, fork PRs, or quality (lint) mode, see Common Patterns below.
Honest comparison — diff-cover-action is not always the right pick. Here's where each tool wins:
| diff-cover-action | Codecov | Coveralls | 5monkeys/cobertura-action | |
|---|---|---|---|---|
| Setup | 1 workflow file | App install + token | App install + token | 1 workflow file |
| Where it runs | Your CI runner | SaaS (data uploaded) | SaaS (data uploaded) | Your CI runner |
| Pricing | Free (OSS, MIT) | Free for OSS, paid private | Free for OSS, paid private | Free (OSS, MIT) |
| Coverage scope | Changed lines only | Full repo + diff | Full repo + diff | Changed lines only |
| Lint / quality reporting | 13+ tools (ruff, eslint, mypy, …) | — | — | — |
| PR comment | Idempotent updates | Idempotent updates | Idempotent updates | Idempotent updates |
| Inline diff annotations | Yes | Yes | Yes | — |
| Actions step summary | Yes | — | — | — |
| Historical trend graphs | — | Yes | Yes | — |
| Org-wide dashboards | — | Yes | Yes | — |
| Coverage formats | Cobertura XML, lcov, JaCoCo, Clover | Cobertura, lcov, JaCoCo, +many | Cobertura, lcov, +many | Cobertura XML only |
| Fork PR comments | Skips gracefully (read-only token) | Works via app token | Works via app token | Skips gracefully |
| Data leaves your CI? | No | Yes | Yes | No |
Pick diff-cover-action if you want diff coverage and diff quality (lint) in one step, you don't want vendor signups, and you don't need cross-PR trend history.
Pick Codecov / Coveralls if you need historical trend graphs, organization dashboards, full-repo coverage tracking on non-PR commits, or coverage history retained outside CI logs.
Pick 5monkeys/cobertura-action if all you need is a Cobertura PR comment and you don't care about lint/quality, lcov, or JaCoCo.
- Diff coverage -- find lines in your PR that aren't covered by tests
- Diff quality -- find lint/style violations only in changed code (flake8, pylint, ruff, mypy, eslint, and more)
- PR comments -- auto-posted, idempotent (updates on re-run instead of spamming)
- Inline annotations -- uncovered lines appear directly in the PR diff view
- Step summaries -- coverage summary in the Actions run UI
- Structured outputs -- coverage %, violation count, threshold status for downstream steps
- Badge generation -- shields.io endpoint JSON for README badges
- Shallow clone handling -- automatically fetches enough history for diff-cover
- Full CLI parity -- every diff-cover/diff-quality flag is exposed as an input
| Category | Tools |
|---|---|
| Coverage formats | Cobertura XML, lcov, JaCoCo XML, Clover XML |
| Python linters | ruff, flake8, pylint, pycodestyle, pyflakes, mypy, pydocstyle |
| JavaScript linters | eslint, jshint |
| Other | checkstyle, checkstylexml, clang, cppcheck, shellcheck |
Works with any language that can produce Cobertura XML, lcov, or JaCoCo coverage reports (Python, JavaScript, TypeScript, Java, Go, Rust, C#, Ruby, PHP, and more).
![]()
![]()
████████████████░░░░82.0% on changed lines50 lines changed 9 uncovered 3 files
📂 3 files changed
File Coverage Uncovered Lines 🔴 src/bar.py60.0% 5, 6, 7, 8, 15, 22 🟡 src/foo.py85.0% 13, 27, 42 🟢 src/baz.py100.0% —
![]()
![]()
█████████░░░░░░░░░░░45.0% on changed lines80% required — missing 35.0% more coverage
120 lines changed 66 uncovered 8 files
📂 8 files changed
File Coverage Uncovered Lines 🔴 src/payments/stripe.py0.0% 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 (+12 more) 🔴 src/auth/login.py25.0% 15, 16, 17, 30, 31, 32 🔴 src/api/routes.py40.0% 22, 23, 55, 56, 57 🔴 src/models/user.py50.0% 8, 9, 44 🔴 src/utils/cache.py60.0% 18, 19 🟡 src/services/email.py70.0% 33 🟡 src/config.py80.0% 5 🟢 src/middleware.py100.0% —
![]()
![]()
███████████████░░░░░75.0% on changed lines90% required — missing 15.0% more quality coverage
20 lines changed 4 violations 1 file
📂 1 file changed
File Quality Violation Lines 🟡 src/module.py75.0% 10, 11, 12, 30
Warning: src/bar.py lines 5-8 are not covered by tests
Warning: src/bar.py line 15 is not covered by tests
Warning: src/bar.py line 22 is not covered by tests
Warning: src/foo.py line 13 is not covered by tests
Each annotation is also attached to its file and line, so it renders inline on the PR diff. The path is repeated in the message text because the Actions log view shows only the message.
The same coverage table also appears in the Actions > Job Summary tab so you can see results without opening the PR. Set summary-title to head that block when earlier steps in the job have already written to the summary.
name: Coverage
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: |
pip install pytest pytest-cov
pytest --cov --cov-report=xml
- name: Diff Coverage
uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
fail-under: '80' - name: Diff Quality
uses: Affanmir/diff-cover-action@v2
with:
mode: quality
violations: ruff.check
fail-under: '90' - name: Diff Coverage
uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
fail-under: '80'
- name: Diff Quality
uses: Affanmir/diff-cover-action@v2
with:
mode: quality
violations: flake8
fail-under: '90'
comment-identifier: diff-quality # separate comment from coverage| Input | Description | Default |
|---|---|---|
mode |
"coverage" or "quality" |
coverage |
| Input | Description | Default |
|---|---|---|
coverage-files |
Space-separated coverage report paths (XML/lcov). Globs supported. | required |
| Input | Description | Default |
|---|---|---|
violations |
Tool: pycodestyle, pyflakes, flake8, pylint, ruff.check, mypy, checkstyle, checkstylexml, clang, cppcheck, eslint, jshint, pydocstyle, shellcheck |
required |
quality-input-reports |
Pre-generated violation report files (space-separated) | |
quality-options |
Pass-through arguments to the violations tool |
| Input | Description | Default |
|---|---|---|
compare-branch |
Branch to diff against | origin/main |
diff-range-notation |
"..." or ".." |
... |
diff-file |
Pre-generated diff file path | |
ignore-staged |
Exclude staged changes | false |
ignore-unstaged |
Exclude unstaged changes | false |
include-untracked |
Include untracked files | false |
ignore-whitespace |
Ignore whitespace in diff | false |
| Input | Description | Default |
|---|---|---|
exclude |
Newline-separated fnmatch exclude patterns | |
include |
Newline-separated glob include patterns | |
src-roots |
Space-separated source roots (for JaCoCo reports) |
| Input | Description | Default |
|---|---|---|
expand-coverage-report |
Extend coverage for multi-line statements (XML only) | false |
show-uncovered |
Show uncovered lines in console output | false |
quiet |
Suppress non-error output | false |
config-file |
TOML configuration file path |
| Input | Description | Default |
|---|---|---|
fail-under |
Minimum acceptable percentage (0-100) | 0 |
fail-on-threshold |
Fail the step when below threshold | true |
| Input | Description | Default |
|---|---|---|
title |
Optional H2 heading shown at the top of the PR comment (e.g. app name in a monorepo). Empty = no heading. | |
summary-title |
Optional H2 heading shown at the top of the job step summary. Useful when earlier steps already wrote to the summary and you need a separator. Empty = no heading. |
| Input | Description | Default |
|---|---|---|
github-token |
Token for PR comments and annotations | ${{ github.token }} |
post-comment |
Post/update PR comment with results | true |
comment-identifier |
Unique marker for idempotent comment updates | diff-cover-action |
create-annotations |
Create inline annotations on uncovered lines | true |
annotation-type |
"warning", "error", or "notice" |
warning |
annotation-limit |
Max annotations (GitHub caps at 50/job) | 50 |
create-badge |
Generate shields.io endpoint JSON | false |
badge-filename |
Badge JSON output filename | diff-cover-badge.json |
| Output | Description |
|---|---|
total-percent |
Coverage/quality percentage (integer) |
total-percent-float |
Percentage with 2 decimal places |
total-lines |
Total lines in the diff |
total-violations |
Uncovered or violating lines |
files-changed |
Number of files with changes |
threshold-met |
"true" or "false" |
comment-id |
PR comment ID |
report-json |
Path to JSON report file |
badge-json |
Path to badge JSON file |
exit-code |
Raw diff-cover CLI exit code |
- name: Diff Coverage
id: coverage
uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
- name: Check result
if: steps.coverage.outputs.threshold-met == 'false'
run: echo "Coverage is ${{ steps.coverage.outputs.total-percent }}%"The action needs these permissions for full functionality:
permissions:
contents: read # Read repository content
pull-requests: write # Post PR comments
checks: write # Create check annotations (optional, for >10 annotations)When a PR comes from a fork, GITHUB_TOKEN has read-only permissions. The action detects this and:
- Skips PR comment posting (with a
::noticemessage) - Annotations via workflow commands still work
To enable comments on fork PRs, use pull_request_target:
on:
pull_request_target:
types: [opened, synchronize]The action automatically handles shallow clones (the default for actions/checkout). It progressively fetches history until the merge-base is available. For fastest operation on large repos, set:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history, fastest for diff-coverEnable badge generation and use with shields.io:
- name: Diff Coverage
uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
create-badge: 'true'
- name: Upload badge
uses: actions/upload-artifact@v4
with:
name: coverage-badge
path: diff-cover-badge.jsonThen use with a shields.io endpoint badge.
- uses: Affanmir/diff-cover-action@v2
with:
coverage-files: 'unit-coverage.xml integration-coverage.xml' - uses: Affanmir/diff-cover-action@v2
with:
coverage-files: '**/coverage*.xml' - uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
exclude: |
tests/*
setup.py
**/migrations/* - uses: Affanmir/diff-cover-action@v2
with:
coverage-files: target/site/jacoco/jacoco.xml
src-roots: 'src/main/java' - uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
config-file: pyproject.tomlWhere pyproject.toml contains:
[tool.diff_cover]
compare_branch = "origin/develop"
fail_under = 80
exclude = ["tests/*", "setup.py"]When several coverage jobs run in a matrix, give each comment its own heading and a unique comment-identifier so they don't overwrite each other:
jobs:
coverage:
strategy:
matrix:
app: [online-store, cms, partners-app]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm test:unit:${{ matrix.app }}
- uses: Affanmir/diff-cover-action@v2
with:
title: ${{ matrix.app }}
coverage-files: apps/${{ matrix.app }}/coverage/lcov.info
comment-identifier: diff-cover-${{ matrix.app }}
fail-under: '80'Each PR gets one comment per app, headed with the app name.
Report coverage without failing the step:
- name: Coverage Report
id: coverage
uses: Affanmir/diff-cover-action@v2
with:
coverage-files: coverage.xml
fail-under: '80'
fail-on-threshold: 'false' # Don't fail, just report
- name: Warn if low
if: steps.coverage.outputs.threshold-met == 'false'
run: echo "::warning::Coverage is below 80%"- Git setup -- detects shallow clones and progressively fetches history
- CLI execution -- builds and runs
diff-coverordiff-qualitywith your options - Report parsing -- parses the JSON output into structured data
- PR comment -- renders a markdown comment and creates/updates it on the PR
- Annotations -- emits
::warningworkflow commands for uncovered lines - Outputs -- writes
GITHUB_OUTPUTvalues andGITHUB_STEP_SUMMARY
See CONTRIBUTING.md for development setup and guidelines. Participation in this project is governed by the Code of Conduct.
Found a vulnerability? Please report it privately — see SECURITY.md for the reporting policy and supported versions.