https://github.com/evansims/coverlint
Coverage checks for GitHub Actions — no external services, secrets, or accounts required. Supports Go, Rust, TypeScript, Python, PHP, Java, and more.
https://github.com/evansims/coverlint
ci cobertura code-coverage coverage-threshold github-actions jacoco lcov pull-requests
Last synced: 3 months ago
JSON representation
Coverage checks for GitHub Actions — no external services, secrets, or accounts required. Supports Go, Rust, TypeScript, Python, PHP, Java, and more.
- Host: GitHub
- URL: https://github.com/evansims/coverlint
- Owner: evansims
- License: apache-2.0
- Created: 2026-03-08T22:10:08.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-03-29T22:53:01.000Z (3 months ago)
- Last Synced: 2026-04-04T10:55:58.474Z (3 months ago)
- Topics: ci, cobertura, code-coverage, coverage-threshold, github-actions, jacoco, lcov, pull-requests
- Language: Go
- Homepage:
- Size: 235 KB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE-APACHE
Awesome Lists containing this project
README
# Coverlint

Coverage checks for GitHub Actions — no external services, no secrets, no accounts. Add one step to your workflow, set a threshold, and get pass/fail results with annotations and a job summary.
Coverlint parses coverage reports in [all major formats](#supported-formats), enforces configurable thresholds, and runs entirely within your GitHub Actions runner. Supports Linux, macOS, and Windows.
## Supported Formats
- **LCOV** (`lcov`) — `cargo llvm-cov`, `c8`, `istanbul`, `jest`, `vitest`
- **Go cover profile** (`gocover`) — `go test -coverprofile`
- **Cobertura XML** (`cobertura`) — `pytest-cov`, `istanbul`, `cargo tarpaulin`
- **Clover XML** (`clover`) — `phpunit`, some JS tools
- **JaCoCo XML** (`jacoco`) — Gradle/Maven JaCoCo plugin
## Usage
Add coverlint after your test step. Without any inputs, it auto-detects the format, finds your report, and shows coverage without enforcing a threshold — handy for tracking trends before you commit to a minimum:
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
```
To enforce a minimum, set `min-coverage` — a combined score across line, branch, and function coverage (see [Custom Weights](#custom-weights) for how the score is computed):
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
min-coverage: 80
```
Set `format` explicitly for faster runs and to avoid ambiguity when files share names (e.g. `coverage.xml` could be Cobertura or Clover):
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
min-coverage: 80
```
## Quick Start by Language
Go
```yaml
- run: go test -coverprofile=cover.out ./...
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: gocover
min-coverage: 80
```
Rust
```yaml
- run: cargo llvm-cov --lcov --output-path lcov.info
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
min-coverage: 80
```
TypeScript / JavaScript
```yaml
- run: npx vitest run --coverage --coverage.reporter=lcov
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
min-coverage: 80
```
Python
```yaml
- run: pytest --cov --cov-report=xml:coverage.xml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: cobertura
min-coverage: 80
```
PHP
```yaml
- run: vendor/bin/phpunit --coverage-clover=coverage.xml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: clover
min-coverage: 80
```
Java (Gradle)
```yaml
- run: ./gradlew test jacocoTestReport
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: jacoco
min-coverage: 80
```
## Thresholds
### Coverage Score
When you set `min-coverage`, coverlint computes a weighted score from line (50), branch (30), and function (20) coverage. If your format doesn't report a metric — like branch and function in `gocover` — its weight redistributes to the rest.
### Custom Weights
Weights are relative — adjust them to match what matters to your project:
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
min-coverage: 80
weight-line: 100 # only line coverage counts toward the score
weight-branch: 0
weight-function: 0
```
### Per-Metric Floors
Set `min-line`, `min-branch`, or `min-function` to require a minimum for a single metric, regardless of the overall score. Combine with `min-coverage` to enforce both:
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
min-coverage: 80
min-branch: 60 # fails if branch drops below 60%, even if the overall score passes
```
> [!NOTE]
> If you set a floor that your format doesn't support (e.g. `min-branch` with `gocover`), it's skipped with a warning annotation.
### Per-Area Thresholds
Use separate steps when parts of your project need different bars:
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: gocover
path: cover.out
min-coverage: 80
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: lcov
path: coverage/lcov.info
min-coverage: 90
```
## Monorepo
Combine coverage from multiple languages in one step — you'll get a job summary that breaks down each format with a combined total. Use YAML block scalars (`|`) to pass multiple values:
```yaml
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
with:
format: |
gocover
lcov
cobertura
path: |
go-service/cover.out
node-service/coverage/lcov.info
python-service/coverage.xml
min-coverage: 80
```
## Auto-Detection and Discovery
You don't need to specify `format` or `path` — coverlint can figure both out. It tries each parser until one succeeds, and looks for reports in common locations:
| Format | Searched Paths |
| ----------- | ----------------------------------------------------------------------------------------------- |
| `lcov` | `coverage/lcov.info`, `lcov.info`, `coverage.lcov` |
| `gocover` | `cover.out`, `coverage.out`, `c.out` |
| `cobertura` | `coverage.xml`, `cobertura.xml`, `cobertura-coverage.xml` |
| `clover` | `coverage.xml`, `clover.xml` |
| `jacoco` | `build/reports/jacoco/test/jacocoTestReport.xml`, `target/site/jacoco/jacoco.xml`, `jacoco.xml` |
## Baseline & Regression Detection
Catch coverage regressions before they land. Pass a previous run's baseline as JSON and set `min-delta` to control how far the score can drop — `0` fails on any decrease, `-2` allows up to a 2-point drop. Skip `baseline` entirely if you don't need delta comparison yet.
Each run emits its own `baseline` output as JSON — store it and feed it back next time. The workflow below keeps the baseline on an orphan branch, loading it before each run and updating it after merges to `main`:
```yaml
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
baseline: ${{ steps.coverage.outputs.baseline }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# ... your test steps ...
- name: Load previous baseline
id: load-baseline
env:
REPO: ${{ github.repository }}
run: |
baseline=$(curl -fsL "https://raw.githubusercontent.com/${REPO}/coverlint/coverage-baseline.json" 2>/dev/null || true)
echo "baseline=${baseline}" >> "$GITHUB_OUTPUT"
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
id: coverage
with:
format: gocover
min-coverage: 80
baseline: ${{ steps.load-baseline.outputs.baseline }}
min-delta: -2
update-baseline:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
concurrency:
group: coverlint-update
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Push baseline
env:
BASELINE: ${{ needs.test.outputs.baseline }}
run: |
tmpdir=$(mktemp -d)
printf '%s' "$BASELINE" > "$tmpdir/coverage-baseline.json"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code origin coverlint &>/dev/null; then
git fetch origin coverlint
git checkout coverlint
else
git checkout --orphan coverlint
git rm -rf . 2>/dev/null || true
fi
cp "$tmpdir/coverage-baseline.json" .
git add coverage-baseline.json
git diff --cached --quiet && exit 0
git commit -m "Update coverage baseline"
git push origin coverlint
```
## Code Scanning Integration
See uncovered lines and blocks right in GitHub's Code Scanning tab. Enable `sarif: true` and upload the [SARIF](https://sarifweb.azurewebsites.net/) output alongside your test results:
```yaml
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
sarif: ${{ steps.coverage.outputs.sarif }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# ... your test steps ...
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
id: coverage
with:
format: lcov
sarif: true
upload-sarif:
needs: test
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Write SARIF file
env:
SARIF: ${{ needs.test.outputs.sarif }}
run: printf '%s' "$SARIF" > coverage.sarif
- uses: github/codeql-action/upload-sarif@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
with:
sarif_file: coverage.sarif
```
Results are capped at 500 by default. For large codebases, lower the cap to stay within GitHub's 1 MB action output limit — pass a number instead of `true` (e.g. `sarif: 200`).
## PR Comments
Give reviewers coverage context without leaving the PR. Use the `results` output to post a summary comment:
```yaml
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
results: ${{ steps.coverage.outputs.results }}
passed: ${{ steps.coverage.outputs.passed }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# ... your test steps ...
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
id: coverage
with:
format: gocover
min-coverage: 80
comment:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment on PR
env:
GH_TOKEN: ${{ github.token }}
RESULTS: ${{ needs.test.outputs.results }}
PASSED: ${{ needs.test.outputs.passed }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
score=$(echo "$RESULTS" | jq -r '.[-1].score // empty') || exit 0
status="Pass"
if [[ "$PASSED" != "true" ]]; then status="**Fail**"; fi
body="**Coverage:** ${score}% — ${status}"
gh pr comment "$PR_NUMBER" --edit-last --body "$body" 2>/dev/null || \
gh pr comment "$PR_NUMBER" --body "$body"
```
## Coverage Badges
Show live coverage in your README — no external services or secrets needed:
```yaml
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
badge-svg: ${{ steps.coverage.outputs.badge-svg }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# ... your test steps ...
- uses: evansims/coverlint@aa15ba0901ffaad70113c3ab5a54bf2e676614f8 # v1.2.5
id: coverage
with:
format: gocover
min-coverage: 80
update-badges:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
concurrency:
group: coverlint-update
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Push coverage badge
env:
BADGE_SVG: ${{ needs.test.outputs.badge-svg }}
run: |
tmpdir=$(mktemp -d)
printf '%s' "$BADGE_SVG" > "$tmpdir/coverage.svg"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code origin coverlint &>/dev/null; then
git fetch origin coverlint
git checkout coverlint
else
git checkout --orphan coverlint
git rm -rf . 2>/dev/null || true
fi
cp "$tmpdir/coverage.svg" .
git add coverage.svg
git diff --cached --quiet && exit 0
git commit -m "Update coverage badge"
git push origin coverlint
```
Add to your README:
```markdown

```
Prefer [shields.io](https://shields.io) styling? Use `badge-json` instead:
```markdown

```
## Inputs
| Input | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| `format` | Coverage format(s), one per line or comma-separated. Auto-detected if omitted |
| `path` | Path(s) to coverage files, one per line or comma-separated. Supports globs. Auto-discovered if omitted |
| `min-coverage` | Minimum weighted coverage score (0-100), computed from line, branch, and function coverage |
| `min-line` | Minimum line coverage (0-100), checked independently of the weighted score |
| `min-branch` | Minimum branch coverage (0-100), checked independently |
| `min-function` | Minimum function coverage (0-100), checked independently |
| `weight-line` | Relative weight for line coverage in score (default: `50`) |
| `weight-branch` | Relative weight for branch coverage in score (default: `30`) |
| `weight-function` | Relative weight for function coverage in score (default: `20`) |
| `working-directory` | Working directory for resolving relative paths (default: `.`) |
| `fail-on-error` | Fail the action when thresholds are not met (default: `true`) |
| `suggestions` | Show top coverage improvement opportunities in job summary (default: `true`) |
| `annotations` | Annotation output: `true` (default), `false`, or a max count |
| `baseline` | JSON string of previous baseline data for delta comparison |
| `min-delta` | Minimum allowed score change (e.g. `0` = no regression, `-2` = max 2pt drop). Ignored without `baseline` |
| `sarif` | SARIF output: `true` (default max 500 results), `false`, or a number (default: `false`) |
## Outputs
| Output | Description |
| ------------ | -------------------------------------------------------------------------- |
| `passed` | Whether all thresholds were met (`true` or `false`) |
| `results` | Coverage data as JSON |
| `badge-svg` | Ready-to-use SVG coverage badge |
| `badge-json` | Coverage badge as [shields.io](https://shields.io) endpoint JSON |
| `baseline` | Current run's baseline as JSON — store and feed back as the `baseline` input next run |
| `sarif` | SARIF JSON for uploading to GitHub Code Scanning |
Example results output
The `results` JSON has one entry per format, each with a weighted `score` and available metrics. Multi-format runs include a `Total`:
```json
[
{ "name": "gocover", "score": 85, "line": 85, "passed": true },
{
"name": "lcov",
"score": 77,
"line": 78.3,
"branch": 65.2,
"function": 90.1,
"passed": true
},
{
"name": "Total",
"score": 79,
"line": 81.1,
"branch": 65.2,
"function": 90.1,
"passed": true
}
]
```
Use GitHub Actions' `fromJSON()` expression to read values in later steps:
```yaml
- env:
LINE: ${{ fromJSON(steps.coverage.outputs.results)[0].line }}
run: echo "Line coverage is ${LINE}%"
```
## Exit Codes
| Code | Meaning |
| ---- | ----------------------------------------- |
| 0 | All checks passed |
| 1 | Coverage below threshold |
| 2 | Configuration, parse, or unexpected error |
This distinction helps you tell apart "coverage is too low" from "something is broken." If you see exit 1, your tests ran fine but coverage fell short. Exit 2 usually means the action step itself needs fixing.
## Pinning
[Pin actions by commit SHA](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) in production workflows and use [Dependabot](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates) to keep them current. All releases use [immutable tags](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases), and the binary is checksum-verified on every download.
## Contributing
Clone and run the tests — standard Go tooling, nothing extra needed:
```bash
git clone https://github.com/evansims/coverlint.git && cd coverlint
go test -race -cover ./...
go vet ./...
```
## License
Dual-licensed under [Apache 2.0](LICENSE-APACHE) and [MIT](LICENSE-MIT). Choose whichever you prefer.