An open API service indexing awesome lists of open source software.

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.

Awesome Lists containing this project

README

          

# Coverlint

![Coverage](https://raw.githubusercontent.com/evansims/coverlint/badges/coverage.svg)

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
![Coverage](https://raw.githubusercontent.com/OWNER/REPO/coverlint/coverage.svg)
```

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

```markdown
![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/OWNER/REPO/coverlint/coverage.json)
```

## 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.