CI integration
A muhar step in CI has three parts: install a pinned muhar, run the spec, and fail unless the exit status is 0 and the Totals: line shows zero NOT CAUGHT and zero could-not-mutate. Everything on this page builds on the gate rule.
A single job
GitHub Actions, one spec, one runner:
jobs:
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install muhar
env:
BASE: https://github.com/medici-finance/assay/releases/download/v1.0.39
run: |
set -euo pipefail
curl -fsSLO "$BASE/desk-tools-linux-amd64.tar.gz"
curl -fsSLO "$BASE/checksums.txt"
grep " desk-tools-linux-amd64.tar.gz\$" checksums.txt | sha256sum -c -
tar -xzf desk-tools-linux-amd64.tar.gz ./muhar
install -m 0755 muhar "$RUNNER_TEMP/muhar"
- name: Mutation gate
shell: bash
run: |
set -uo pipefail
status=0
report=$("$RUNNER_TEMP/muhar" -spec mutations.json) || status=$?
printf '%s\n' "$report"
if [ "$status" -ne 0 ]; then
echo "::error::muhar exited $status: harness broken or usage error, no verdict"
exit 1
fi
if ! grep -qE '^Totals: [0-9]+ caught, 0 NOT CAUGHT, 0 could-not-mutate\.$' <<<"$report"; then
echo "::error::a guard has no test, or a mutation did not land"
exit 1
fi
Notes:
- The step uses
set -uo pipefailwithout-e, so a non-zero muhar exit is captured instatusand reported, instead of ending the step before the message. - The suite's output goes to stderr and appears in the log above the report. The report itself is printed once, by
printf. actions/setup-gois needed only because this example's suite isgo test. muhar itself needs no Go toolchain, except for the replace rewrite with-jabove 1.- Pin the release in
BASE. Withlatest, a new release can change the gate without a commit.
Splitting across matrix legs
A large spec can take longer than one job should. -shard i/n runs a disjoint share of the mutations; n legs with i from 0 to n-1 cover every mutation exactly once (CLI reference). Every leg proves the baseline and control on its own, so a broken harness fails all of them.
jobs:
mutation:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: ["0/3", "1/3", "2/3"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install muhar
run: ./ci/install-muhar.sh
- name: Mutation gate, shard ${{ matrix.shard }}
shell: bash
env:
MUHAR_SHARD: ${{ matrix.shard }}
run: |
set -uo pipefail
status=0
report=$(muhar -j 0 -shard "$MUHAR_SHARD" -spec mutations.json) || status=$?
printf '%s\n' "$report"
mkdir -p "$RUNNER_TEMP/muhar-reports"
printf '%s\n' "$report" > "$RUNNER_TEMP/muhar-reports/shard-${MUHAR_SHARD%/*}.txt"
if [ "$status" -ne 0 ]; then
echo "::error::shard $MUHAR_SHARD: muhar exited $status, no verdict"
exit 1
fi
grep -qE '^Totals: [0-9]+ caught, 0 NOT CAUGHT, 0 could-not-mutate\.$' <<<"$report" || {
echo "::error::shard $MUHAR_SHARD: a guard has no test, or a mutation did not land"
exit 1
}
- uses: actions/upload-artifact@v4
if: always()
with:
name: muhar-shard-${{ strategy.job-index }}
path: ${{ runner.temp }}/muhar-reports/
./ci/install-muhar.sh stands for the install step from the single-job example; it must put muhar on PATH.
- Pass the shard through an environment variable rather than interpolating
${{ matrix.shard }}into the script. It keeps the expression out of the shell text. fail-fast: falselets every leg finish, so one run shows every missing test, not the first shard's.- The matrix list must name every
i/nfor onen. A missing leg silently drops its mutations from the gate. Generate the list, or keep it next to a comment statingn. - Each leg's
Totals:line covers that leg only. The gate is the conjunction: every leg green. - Every leg pays for the baseline and the control. Shard when the mutations dominate the run time, not for a spec of three.
Sizing -j
-j 0 uses one worker per CPU, capped at 2, which bounds peak memory on large runners (why). On a runner with headroom, an explicit -j 4 may be faster; measure it, because each worker holds a full copy of root and its own suite process. Combine -j with -shard: shard across machines, -j within one.
Point root at the smallest tree the suite needs. Every worker copies everything under it, including .git and node_modules.
What to keep
Keep the report — stdout — as a build artifact, as in the matrix example. The suite's output on stderr is in the job log. A COULD_NOT_MUTATE line names the mutation and why it did not land, so the report alone is enough to fix the spec.
Other CI systems
The gate is plain bash, so it carries over unchanged. In GitLab CI:
mutation:
image: golang:1.25
parallel:
matrix:
- MUHAR_SHARD: ["0/3", "1/3", "2/3"]
script:
- ./ci/install-muhar.sh
- ./ci/muhar-gate.sh
where ci/muhar-gate.sh holds the bash gate, reading MUHAR_SHARD. Any system that runs a script and fails on a non-zero exit can run it.
How Assay uses it
Assay's release workflow runs muhar on its own merge tool: three matrix legs, -shard 0/3, 1/3 and 2/3, each go run ./cmd/muhar -j 0 -shard "$MUHAR_SHARD" -spec cmd/deskmerge/mutations.json from the tools/desk module, gated on the same Totals: pattern. A second leg runs six smaller specs one after another with -j 0. Reports are written to the runner's temp directory.
Coming
A reusable CI action that installs muhar, runs a sharded sweep and applies this gate is planned (assay#2642), as is a diff-scoped mode that runs only the mutations touching changed files (assay#2640). Neither exists yet; see the roadmap.