A Pitest badge for everyone
Last time, a small Gradle task, GitHub Pages and Shields.io replaced my old hosted service for a Pitest mutation coverage badge. It worked well enough that I copied it into a second project, my GPU-rendered debugging UI Roundabout. And that’s where it started to look wrong: two copies of the same Kotlin task, two copies of the Pages plumbing, and nothing in either that was really about my projects. Every Pitest user who wants a badge has to write the same thing.
So I turned it into a GitHub Action: Pitest Badge.
Two lines
After Pitest has run (Gradle or Maven, it only needs Pitest’s mutations.xml):
- run: ./gradlew pitest
- uses: rossdrew/PitestCI@v1
That’s it. The job needs permissions: contents: write, and the README needs the badge:
[](https://github.com/<owner>/<repo>/actions)
which, for EmuRoxLab, looks like this:
No account to sign up for, no service to trust, no API key to store as a secret. It lives in the repository where my 2017 Python service used to, which felt right.
Where the badge lives
The Gradle task in part two wrote the badge JSON and the CI job deployed it to GitHub Pages with the report. That means Pages has to be set up before you get even a badge, and Pages is a whole website for one small file.
The Action commits the JSON to a badges branch in your own repository instead, and Shields.io reads it straight from raw.githubusercontent.com. The branch keeps a single commit (each update replaces the last one) so it never grows, and it leaves any other files in it alone. main is never touched. It only publishes on a push to your default branch; on pull requests it just reports the score.
What else you get
Every run, pull requests included, writes the score to the job summary with a table per package, which turns out to be the thing I look at most:
Mutation coverage: 75%
780 of 1040 mutants detected.
Package Detected Score com.rox.roundabout203/249 82% com.rox.roundabout.chart86/126 68% com.rox.roundabout.debugger106/165 64% com.rox.roundabout.memory327/434 75% …
And report: true publishes Pitest’s HTML report to Pages too, as part two did.
The scoring got simpler as well. Part two kept its own list of Pitest’s “detected” statuses (KILLED, TIMED_OUT, NON_VIABLE, …). It turns out every <mutation> in the XML already carries a detected="true" attribute, so the Action just counts those. Same number, and no list to keep in step with Pitest.
The report, and a token Pages ignores
My first version published the report the obvious way: push it to a gh-pages branch and let Pages serve it. My tests pushed to the branch, checked the files arrived, and passed. It was released.
Then, while switching Roundabout over, CodeRabbit reviewed the pull request and pointed at a sentence in GitHub’s own docs:
Commits pushed by a GitHub Actions workflow that uses the
GITHUB_TOKENdo not trigger a GitHub Pages build.
So the files would have arrived and the website would never have updated. My tests checked the wrong thing: that the branch changed, not that the site did.
The fix was to go back to part two’s approach for the report. The Action now builds the site (the report plus a copy of the badge) and uploads it with GitHub’s own upload-pages-artifact; a short job in your workflow deploys it. It can’t deploy by itself, because deploy-pages needs the github-pages environment, and only a job can declare that, not an action. Version 1.1.0 was out the same afternoon.
Keeping the write token away from the build
CodeRabbit had a second, quieter point. A job with contents: write that also runs ./gradlew hands a token that can push to your repository to every Gradle plugin and dependency in the build. If one of them were ever compromised, it could use it.
The Action doesn’t need a checkout, so the cure is cheap: run Pitest in a read-only job, upload the report as an artifact, and publish from a job that only downloads it. This is what EmuRoxLab now runs, with the same +fullBuild trigger as before:
mutation:
needs: build
continue-on-error: true
if: contains(github.event.head_commit.message, '+fullBuild') || github.event_name == 'workflow_dispatch'
steps:
# ...checkout, JDK, Gradle...
- run: ./gradlew pitest
- if: always() # even under the mutation threshold, so the badge shows the drop
uses: actions/upload-artifact@v4
with:
name: pitest-report
path: build/reports/pitest
badge:
needs: mutation
if: github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: pitest-report
path: pitest
- uses: rossdrew/PitestCI@v1
with:
reports: pitest/mutations.xml
report: true
publish: true
pages:
needs: badge
permissions:
pages: write
id-token: write
environment:
name: github-pages
steps:
- uses: actions/deploy-pages@v4
The pitestBadge task from part two is gone from build.gradle.kts, and the badge reads the same 97% it did before.
Testing a badge
The Action is a short Python script using only the standard library, so there’s nothing to install on the runner. It has unit tests, and its own workflow runs it for real on two tiny sample projects, one Gradle and one Maven, sharing a deliberately half-tested class. Both have to score exactly 50%. On main it publishes that badge to a branch of its own, and the README shows it, red, as a live example:
Try it
It’s on the GitHub Marketplace, MIT licensed, and the README has the full setup for Gradle and Maven, the report and every option. If you use Pitest and want a badge, I’d love to hear how it goes, and issues are open.
Comments