Troubleshooting¶
Symptom, cause, fix. Exit codes first, because they tell you which half of the page you're in.
| Exit code | Meaning |
|---|---|
0 |
Patch coverage met the threshold, or there was nothing coverable to gate. |
1 |
Patch coverage fell below the threshold. This is the gate doing its job. |
2 |
Broken run, bad setup, or a usage error. Never a coverage verdict. |
Exit 2 is the one to read carefully. A failed, empty or unparseable test run is a tool
error, not 0% coverage, so it turns the build red instead of quietly failing the gate.
The gate passed but the number looks too good¶
The most important entry on this page, because nothing fails and nothing is logged as an error.
Patch coverage only counts changed lines the coverage report actually mentions. A file the report never names contributes nothing, so anything that drops files from the report inflates the result instead of failing it.
Check the denominator first. Covered / changed executable lines in the PR comment, or
total_lines in the outputs. If it's 0 or much smaller than the diff, one of these is
happening:
- A report was dropped. A multi-project .NET solution writes one report per test project,
and a Maven reactor writes one per module. Count them:
find . -name coverage.cobertura.xml | wc -l, orfind . -path '*/target/site/jacoco/jacoco.xml' | wc -l. - The paths don't match. Coverage paths are often absolute or rooted differently from
git diffpaths. Usestrip_prefixto peel a known root. - A JaCoCo report was read as Cobertura. Both use
.xml. Brimyr picks by root element, but an explicitcoverage_file: report.xml:coberturaoverrides that and produces an empty report. Drop the format suffix or use:jacoco. excludeis too broad. A glob like*Migrations*usesfnmatch, where*crosses/, so it matches deeper than you might expect.
the coverage report(s) named no files at all¶
The report parsed but describes nothing, so there is no coverage to gate on. That's a
broken report, not 0%, and it exits 2.
The usual causes:
- JVM: a surefire
<argLine>that overrides instead of appending@{argLine}, which detaches the JaCoCo agent while the build stays green. - .NET:
dotnet testran without--collect:"XPlat Code Coverage", or thecoverlet.collectorpackage isn't referenced by the test project. - Any:
coverage_filepoints at a report from a different run, or at a file the coverage tool wrote before it instrumented anything.
tests did not finish within Ns and were killed¶
The suite hit test_timeout (default 3600 seconds) and was killed. This is a broken run,
exit 2, not 0% coverage.
Raise it if the suite is genuinely that slow:
with:
test_timeout: '7200'
Set '0' to wait indefinitely, which restores the old behaviour: a hung suite then holds
the runner until the job timeout, six hours by default on GitHub-hosted runners.
History is shallow, so the merge-base is unavailable.¶
actions/checkout defaults to fetch-depth: 1, and patch coverage needs the merge base.
Set fetch-depth: 0:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
If you let Brimyr do its own checkout, leave fetch_depth at its default of 0. Locally,
run git fetch --unshallow.
No merge-base could be determined for the given base and head.¶
The two refs share no history. Usually the base branch isn't fetched, or you're comparing across unrelated histories such as an orphan branch.
Fetch the base, or pass --no-merge-base to diff the two refs directly without a common
ancestor.
No test suite detected: the run passed but nothing was gated¶
Not an error. Auto-detection found no ecosystem marker, so no tests ran and there is
nothing to gate: the job is green, gate_result is skipped, and the summary says so
in place of the coverage table.
This is what makes Brimyr safe to provision across a whole org rather than adopt one repo at a time. A charts, Terraform, prompts or docs repo has nothing to test, and failing it would mean a permanently red check on a gate it can never satisfy.
If the repo DOES have tests, this is the bug, not the fix. The warning is loud on purpose. Detection needs one of:
pyproject.toml/setup.py/setup.cfg/requirements.txt/tox.iniplus a real Python test signal: atest_*.pyor*_test.pyfile belonging to this project, outside.venv/node_modules/ other vendored directories, or a pytest section ([tool.pytest.ini_options],[pytest],[tool:pytest]).
"Belonging to this project" means no nested project sits between the repo root and
the test file. A subdirectory with its own pyproject.toml / setup.py /
setup.cfg (a separate deployable such as broker/) is a different project whose
dependencies are not in this environment, so pytest at the root would collect its
files and then fail importing them. Depth alone is fine: backend/tests/test_api.py
with no backend/pyproject.toml is still this repo's suite.
If your tests really do live in a nested project and you want them gated from the
root, add a root pytest section pointing at them (testpaths): an explicit config
wins outright. Or set ecosystem: plus a test_command: that enters the directory.
- package.json plus a jest/vitest config or a non-placeholder test script.
- pom.xml for Java (build.gradle is recognised but not auto-run, see above).
- *.sln / *.slnx / *.csproj in the repo root for .NET.
- A real, non-vendored *.bats file for shell. A tests/ directory on
its own is never enough, and a vendored bats-core submodule under test/bats is
deliberately ignored.
Force it with ecosystem:, or skip detection entirely with coverage_file:. A forced
ecosystem: that then fails is still an error: explicit intent is never downgraded to
a skip.
the test command did not run: ... command not found¶
Nothing on the runner could launch the test binary. Usually that is pytest or jest
on a job that never installed the repo's dependencies. Nothing was measured, so this is
a broken run (exit 2), not 0% coverage.
Brimyr normally installs them for you: with provision on (the default) it uses the
repo's own dependency manager, so a uv.lock repo runs under uv run and a
package.json repo gets an npm ci first. The line above this error says why that
declined. The common ones:
| Line | Fix |
|---|---|
`uv` is not on PATH |
The action installs uv for you when provision is true, so you'll only see this outside the action, e.g. brimyr local. Install uv, or install your test dependencies. |
no installable Python project found |
No pyproject.toml, no requirements*.txt. Add one, or set test_command. |
poetry project, but `poetry` is not on PATH |
A pre-2.0 Poetry layout ([tool.poetry], no [project]). Install Poetry in the job. |
`npm` is not on PATH |
The action installs Node when the runner has none and provision is true, so you'll see this with provision: 'false' or outside the action. Install Node in the job. |
You can always take it over yourself: install the dependencies in an earlier step and
set provision: 'false', set test_command to something that works (which disables
provisioning too), or skip the test run with coverage_file. The full table is in the
Action reference.
dependency install failed¶
The provisioning step itself exited non-zero: npm ci against a stale lockfile,
poetry install on an unresolvable graph, uv run on a lockfile that no longer matches
pyproject.toml. The tests were not run, so again this is a broken run, not a
coverage number. The failing command and its exit code are in the message; run it
locally to see the real error.
The tests ran but the build is red with exit 2¶
That's the broken-run rule. The suite failed, produced no coverage file, or wrote something unparseable. Look at the test output above the Brimyr step: the underlying failure is there, and Brimyr is refusing to convert it into a coverage number.
The one exception is an ecosystem that cannot produce coverage in the first place. A bats
suite emits nothing without kcov, so a passing one is a pass, not a broken run. See
Shell / bats.
--repo ... is not a directory¶
The path given to --repo (or the repo the action ran in) does not exist, or is a
file. Exit 2, and deliberately not a green skip: detection looks for marker files, a
path that is not there has none, and before this was checked a mistyped --repo
reported "no test suite detected" and passed the build.
could not run git ...¶
Git could not be started at all, which is different from git running and failing. Either it is not installed in the job (a slim container without it), or the working directory is unreadable. Exit 2, naming the path. Nothing was measured.
could not write the ... JSON¶
The --json-out or --quality-json-out path could not be written: a permission problem,
a read only filesystem, or a component of the path that is a file rather than a directory.
Missing parent directories are created, so those are not a cause.
This is a warning and nothing more. The gate is already decided by the time the
artifact is written, so the run keeps its own exit code and the summary and PR comment
still go out; only the file is missing. The artifact upload in action.yml is guarded on
the file existing, so nothing downstream reads a stale one.
Tests ran, but no coverage was measured¶
Not an error, and not 0%. The suites that ran passed, and none of them measures coverage:
today that means bats without kcov. gate_result is skipped, the exit
code is 0, and the summary replaces the coverage table so the run cannot be mistaken for
a well tested one.
Install kcov on the runner to turn it into a measurement:
- run: sudo apt-get update && sudo apt-get install -y kcov
Not everything was measured¶
A polyglot run where one ecosystem measured and another did not, for example dotnet,shell
on a runner with no kcov. The percentage above the warning is real, but it covers only
the measured half: a file the coverage report never mentions contributes nothing to the
denominator, so the unmeasured half's changed lines are absent rather than uncovered.
Small pull requests aren't being gated¶
Working as intended. min_lines defaults to 20, so a diff with fewer changed executable
lines than that isn't gated, and the summary says so:
⚪ Only 3 changed executable line(s), below the 20-line minimum, so the 80.0%
threshold was not applied (patch coverage was 33.3%).
Set min_lines: '0' to gate every diff. See Patch coverage.
skipped (sonar-scanner not found on PATH)¶
The scanner install didn't happen or didn't succeed. It only runs when sonar_url is set,
and it's deliberately non-fatal, so the job stays green with no analysis uploaded.
Check that sonar_url is non-empty, and look for an earlier ::warning:: from the install
step. On a container or self-hosted runner without .NET, add actions/setup-dotnet before
Brimyr for .NET repos.
skipped (no token in $SONAR_TOKEN)¶
sonar_url is set but sonar_token is empty. Non-blocking by design, so nothing failed.
Pass the secret, or clear sonar_url if you didn't mean to enable the leg.
skipped (Java / JVM needs sonar.java.binaries)¶
sonar-scanner can't analyse Java without compiled classes. Supply them:
sonar_args: '-Dsonar.java.binaries=**/target/classes'
See SonarQube.
No HTML report artifact appeared¶
html_report needs ReportGenerator, which needs the .NET runtime. On GitHub-hosted runners
the SDK is preinstalled. Elsewhere you'll see:
::warning::html_report needs the .NET runtime, add actions/setup-dotnet
Add actions/setup-dotnet before the Brimyr step. The gate is unaffected either way.
The PR comment isn't posted¶
The comment is failure-isolated, so it never fails the run. Check, in order:
pr_comment: 'true'is set.- The workflow has
pull-requests: write. - The run is on a pull request. There's no PR to comment on for a push.
- The event is
pull_request, notpull_request_target.
Fork pull requests get a read-only token, so the comment can't post. That's a GitHub restriction, not a Brimyr one.
The comment says github-actions[bot] instead of Brimyr[bot]¶
The token broker didn't answer, and the run fell back to the job token. That fallback is
silent and deliberate: the comment still posts, only the byline changes. Check that
token_broker_url is set and the workflow has id-token: write.