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:

[![Mutation coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/<owner>/<repo>/badges/pitest.json)](https://github.com/<owner>/<repo>/actions)

which, for EmuRoxLab, looks like this:

Mutation Coverage

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.roundabout 203/249 82%
com.rox.roundabout.chart 86/126 68%
com.rox.roundabout.debugger 106/165 64%
com.rox.roundabout.memory 327/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_TOKEN do 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:

self-test

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.