# publish-unit-test-result-action, a GitHub Action that turns test files into a check run

> EnricoMi/publish-unit-test-result-action analyses JUnit, NUnit, XUnit, TRX and JSON test result files and publishes them as GitHub checks, so a workflow can show per test results without wiring up a reporting service. It runs as a Docker action on Linux, has non-Docker variants for macOS, Windows and PowerShell-free environments, and ships typings for a type-safe workflow DSL.

**EnricoMi/publish-unit-test-result-action** — GitHub Action to publish unit test results on GitHub

- Repository: https://github.com/EnricoMi/publish-unit-test-result-action
- Stars: 753 · Forks: 235
- Language: Python
- License: Apache-2.0
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/enricomi-publish-unit-test-result-action

## What the action does and which formats it reads

This GitHub Action analyses test result files and publishes the results on GitHub. It supports JSON for Dart and Mocha, TRX for MSTest and Visual Studio, and XML in the JUnit, NUnit and XUnit flavours, and it runs on Linux, macOS and Windows.

The output is a GitHub check run, which is the mechanism that makes individual test names visible on the pull request page rather than only a pass or fail badge. That is the whole value proposition: the results end up where the reviewer already is, and the diff view shows which tests changed state.

The format coverage table in the README is the practical part, because it maps test environments to the file each one produces. Dart and Flutter produce JSON, Jest and Maven produce JUnit XML, Mocha produces either XUnit XML or JSON, and the .NET tooling produces TRX. Almost every mainstream test runner has an output this action can read, and the table also flags the exceptions, such as Mocha noting that its xunit output is not xunit.

The action is used in a workflow like this:

```yaml
- name: Publish Test Results
  uses: EnricoMi/publish-unit-test-result-action@v2
  if: (!cancelled())
  with:
    files: |
      test-results/**/*.xml
      test-results/**/*.trx
      test-results/**/*.json
```

The files input takes glob patterns, and the three extensions are listed because the action reads all three formats. The if clause is the other thing in that snippet, and the README explains why: the condition guarantees the action always runs, even if an earlier step such as the test step failed, but not if the workflow was cancelled.

That is a deliberate choice and it is the right one. If the action were skipped on test failure, you would lose the results of the run that failed, which is exactly the run you most want to look at.

## The variants and why there are so many

The action ships in several forms, and the reason is the execution environment rather than the functionality.

On Ubuntu and ARM Linux runners, including self-hosted runners that support Docker, you use the plain EnricoMi/publish-unit-test-result-action@v2 form and it runs as a Docker container. That is the primary path.

On macOS runners no Docker is needed, so there is a separate /macos@v2 variant. On Windows runners there is a /windows@v2 variant, and for Windows without PowerShell installed there is a /windows/bash@v2 variant that uses the Bash shell instead. For self-hosted Linux runners without Docker there is a /linux@v2 variant, and the README links to notes on running the action as a non-Docker action.

The practical consequence is that the reference you write depends on your runner, which is a small piece of friction that the action absorbs for you rather than you working out which variant your environment needs.

One behavioural detail applies across all of them. When the action runs more than once in a single workflow, the check_name option has to be set to a unique value for each instance, otherwise the multiple runs overwrite each other's results. That is worth knowing if your workflow publishes results for several projects or modules in one job.

Another detail worth reading before you rely on it: by default the action does not fail if tests failed. That is configurable through action_fail. The reasoning is in the README, and it is sound: the step that executed the tests should fail on test failure, while the published results indicate failure independently, configurable through fail_on. Separating the two means the test step fails the build as you expect while the results are still published for inspection.

## Permissions, forks and the fork problem

The permissions section is where most of the friction with this action comes from, and the README is precise about it.

For a public repository the minimal workflow job permissions are checks write and pull-requests write:

```yaml
permissions:
  checks: write
  pull-requests: write
```

For a private repository you also need contents read and issues read:

```yaml
permissions:
  contents: read
  issues: read
  checks: write
  pull-requests: write
```

The private case needs the extra reads because the action reaches into the repository to find the commit and to comment, and the public case does not need them.

There is a specific saving here. With comment_mode set to off, the pull-requests write permission is not needed at all, which is the configuration to pick if you only want the check run and do not want per-test comments on the pull request.

The other problem the README flags is the Resource not accessible by integration error, which comes up when a pull request comes from a fork. The token in a fork's workflow is read-only, so the action cannot write the check run. The README points at two fixes: granting the additional permissions, and setting up the support for pull requests from fork repositories and branches created by Dependabot.

The second fix is the one that is usually missed. The pull_request_target event gives a workflow access to a writable token even for fork contributions, and because it runs with the base repository's context, it is the event you need for a step that writes to the pull request. It is also the event with the security caveat that you must not check out and run untrusted fork code with it, which is why the pattern is to run the tests in the normal pull_request event and publish the results in a separate pull_request_target step. The typings file, action-types.yml, is what lets a type-safe workflow DSL catch mistakes in this configuration.

## Where this fits against the alternatives

The alternative most teams already have is a reporting service. SonarQube, Codacy, CircleCI, or the test reporting built into a hosted CI system all do the same job of turning test files into something you can browse, and they do it with a richer interface than a check run provides.

The difference in approach is where the results live and what the workflow looks like. A reporting service is a separate system with its own storage, its own retention policy and its own account, and the results leave your repository. A check run is a GitHub object attached to the commit, so it is there when someone looks at the pull request and it is gone when the branch is deleted.

That is the reason to use this action even if you already have a reporting service, and it is also the reason to be careful about doing both. The check run is for the reviewer in the pull request, where the value of knowing which test broke is immediate and specific. The reporting service is for the trend over months, where a single run tells you little. Running both is reasonable. Running only the reporting service and expecting people to open a third-party site during review is a plan that does not survive contact with a busy team.

The second alternative is doing nothing and letting the workflow fail. That gives you a red build and no information about which of four hundred tests broke, and it is what you have before you add any of this.

The practical limits of the action are worth naming too. It is a reporter, not a test runner, so it does not fix the problem of a flaky test, and the check run it produces is a view of the file rather than a re-execution. And the format support is bounded by what the test runner emits, so a runner with a proprietary output format needs a conversion step before this action can read it.

For a repository that wants results visible in the pull request and nothing more, this is the smallest thing that does the job, and it is maintained actively, with the last release v2.25.0 on 2026-10-12 and a prior v2.24.0 in June 2026.

## Conclusion

Adopt publish-unit-test-result-action if you want per test results visible on the pull request page without running a reporting service. Skip it if you already publish results to a platform such as SonarQube or a dashboard, because duplicating them is noise. Verify first by running it on a workflow that produces a JUnit file, granting the checks and pull-requests write permissions, and confirming the check run appears with individual test names rather than a single summary.

## FAQ

### Which test result formats does publish-unit-test-result-action read?

JSON for Dart and Mocha, TRX for MSTest and Visual Studio, and XML in the JUnit, NUnit and XUnit flavours. The README includes a table mapping test environments including Dart, Jest, Maven, Mocha and the .NET tooling to the file each produces.

### Why does the workflow need if: (!cancelled())?

It guarantees the action runs even if an earlier step such as the test step failed, so the results of a failing run are still published, but not if the workflow itself was cancelled. The README also notes that by default the action does not fail when tests failed, which is configurable through action_fail.

### What permissions does publish-unit-test-result-action need?

In a public repository, checks write and pull-requests write. In a private repository you also need contents read and issues read. With comment_mode set to off, the pull-requests write permission is not required.

### How do I fix the Resource not accessible by integration error?

The README points at two options, granting the additional permissions, and setting up support for pull requests from fork repositories and branches created by Dependabot, which in practice means using the pull_request_target event for the publishing step while running the untrusted test code in the normal pull_request event.

### What happens if the action runs more than once in one workflow?

The runs overwrite each other's results unless the check_name option is set to a unique value for each instance. The README calls this out explicitly for workflows that publish results for several projects or modules in one job.

## Sources

- [EnricoMi/publish-unit-test-result-action on GitHub](https://github.com/EnricoMi/publish-unit-test-result-action)
- [Issues](https://github.com/EnricoMi/publish-unit-test-result-action/issues)
- [License: Apache-2.0](https://github.com/EnricoMi/publish-unit-test-result-action/blob/master/LICENSE)
- [README](https://github.com/EnricoMi/publish-unit-test-result-action/blob/master/README.md)
- [Releases](https://github.com/EnricoMi/publish-unit-test-result-action/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/enricomi-publish-unit-test-result-action
