A Jenkins Pipeline plugin for sentinel mutation testing. Provides two composable pipeline steps (sentinelRun + sentinelReport) that integrate with standard Jenkins Declarative Pipeline for distributed execution and reporting.
Sentinel is a mutation testing tool for C/C++ projects. It injects small faults (mutants) into your source code and checks whether your tests catch them. The mutation score tells you how effective your tests are.
This plugin provides:
sentinelRun— Runs sentinel with configuration fromSENTINEL_*environment variables. Auto-stashes results.sentinelReport— Unstashes results, merges partitions, generates reports, applies threshold judgment, and displays results in Jenkins UI.
For distributed execution, combine with standard Jenkins parallel stages to split work across multiple nodes, reducing execution time by ~1/N.
- Jenkins 2.479.x LTS or newer
- Java 17+
- Maven 3.9+
- sentinel installed on all Jenkins nodes (or available via Docker)
Current release: 0.1.0
The plugin is not published to the Jenkins update center, so install the .hpi
manually:
- Obtain
sentinel.hpi— either build it from source (mvn -Dchangelist= clean verifyproducestarget/sentinel.hpi; see Cutting a release) or extract it from the Dockerpackagestage (see Development). - In Jenkins, go to Manage Jenkins → Plugins → Advanced settings.
- Under Deploy Plugin, upload
sentinel.hpi. - Restart Jenkins.
Releases follow Semantic Versioning. While the plugin is
on 0.x, the user-facing surface — step parameters and SENTINEL_* variable
names — may still change in a minor release. Check the release notes before
upgrading. The surface will be frozen at 1.0.0.
The simplest usage — runs sentinel on the current agent:
pipeline {
agent { label 'linux' }
environment {
SENTINEL_BUILD_COMMAND = 'make all'
SENTINEL_TEST_COMMAND = 'make test'
SENTINEL_TEST_RESULT_DIR = 'test-results/'
}
stages {
stage('Mutation Test') {
steps {
checkout scm
sentinelRun()
}
}
stage('Report') {
steps {
sentinelReport(
threshold: 80.0,
thresholdAction: 'UNSTABLE'
)
}
}
}
}Project settings (build/test commands, patterns, …) live in a repo sentinel.yaml,
so the Jenkinsfile only carries the Jenkins-side orchestration: how many partitions.
sentinel.yaml (in the repo root):
build-command: make all
test-command: make test
test-result-dir: test-results/Jenkinsfile — one variable n drives both the branch count and the
sentinel --partition denominator, so they can never drift:
def n = 4 // the only place the partition count lives
pipeline {
agent none
stages {
stage('Mutation') {
steps {
script {
def branches = [:]
for (int i = 1; i <= n; i++) {
int idx = i
branches["Partition ${idx}"] = {
node('linux') {
checkout scm
sentinelRun(partitionIndex: idx, partitionTotal: n)
}
}
}
parallel branches
}
}
}
stage('Report') {
agent { label 'linux' }
steps {
checkout scm
sentinelReport(partitionTotal: n,
threshold: 80.0, thresholdAction: 'UNSTABLE')
}
}
}
}Why this shape:
nis the only place the partition count lives — the loop andpartitionTotalderive from it, so a mismatched count is impossible.def nsits abovepipelineso every stage (including Report) can read it.- The branch map is built with a plain
forloop, not(1..n): a GroovyIntRangecannot be serialized by the Pipeline CPS engine whensentinelRuncheckpoints state, so a range-based recipe fails mid-run. Theforloop avoids it, andint idx = icaptures the index per branch so every closure gets its own value. - Fan-out is owned by Jenkins' own
parallel/node(the plugin never allocates nodes), so node allocation, abort, and restart stay with the engine. - The Report stage needs
checkout scmbecause sentinel embeds source code into the HTML report. - Shared seed: sentinel divides mutants by
--partition=i/n, so every partition must select mutants identically for the split to be consistent. The plugin guarantees this automatically: when no seed is configured, everysentinelRunin a build derives the same seed from the build's run ID and passes it as--seed. The build log shows the value ([Sentinel] Using generated seed: N); to reproduce a build, pin it withenvironment { SENTINEL_SEED = 'N' }orsentinelRun(seed: N).
| Where settings live | Needs env? | |
|---|---|---|
| A (recommended) | sentinel.yaml in the repo |
no |
| B | step params: sentinelRun(buildCommand: …, testCommand: …, testResultDir: …) |
no |
| C | environment { SENTINEL_* } block |
yes |
SENTINEL_* environment variables remain fully supported (option C) and are handy
for the matrix recipe below or for run-scoped shared values (e.g. SENTINEL_SEED).
In the single-source recipe above, option A or B means no environment variables are
needed at all.
matrix {
axes { axis { name 'PARTITION'; values '1', '2', '3', '4' } }
agent { label 'linux' }
stages {
stage('Run') {
steps {
checkout scm
sentinelRun(partitionIndex: env.PARTITION.toInteger(), partitionTotal: 4)
}
}
}
}Warning: the number of
axisvalues andpartitionTotal(orSENTINEL_PARTITION_TOTAL) must match — nothing enforces this. A mismatch makes--partition=i/totalwrong and silently overlaps or drops mutants. The plugin fails the report loudly if a partition is missing, but cannot prevent the mismatch. Prefer the single-source recipe when correctness matters. (matrixcells also do not get per-cellpost {}.)
Use Docker agents with the same pattern:
stage('Partition 1') {
agent {
docker {
image 'my-build-env:latest'
label 'linux'
}
}
steps {
checkout scm
sentinelRun(partitionIndex: 1)
}
}Runs sentinel mutation testing. All parameters are optional — configuration comes from SENTINEL_* environment variables, with step parameters overriding env vars when both are set.
Step-specific parameter:
| Parameter | Type | Description |
|---|---|---|
partitionIndex |
int | Partition index (1-based). Combined with SENTINEL_PARTITION_TOTAL env var. |
partitionTotal |
int | Total number of partitions. Overrides SENTINEL_PARTITION_TOTAL. |
Override parameters (override env vars when set):
| Parameter | Type | Env Var | Description |
|---|---|---|---|
buildCommand |
String | SENTINEL_BUILD_COMMAND |
Build command (e.g., make all) |
testCommand |
String | SENTINEL_TEST_COMMAND |
Test command (e.g., make test) |
testResultDir |
String | SENTINEL_TEST_RESULT_DIR |
Test result directory |
sourceDir |
String | SENTINEL_SOURCE_DIR |
Source root directory |
seed |
long | SENTINEL_SEED |
Random seed. Default: derived per build, shared by all partitions (value logged) |
verbose |
boolean | SENTINEL_VERBOSE |
Show detailed output |
workspace |
String | SENTINEL_WORKSPACE |
Sentinel workspace directory |
sentinelPath |
String | SENTINEL_PATH |
Path to sentinel executable |
Seed behavior: if neither seed nor SENTINEL_SEED is set, the plugin
derives a seed from the build's run ID — the same value for every sentinelRun
in the build, a different value each build — and always passes --seed to
sentinel. The log line
[Sentinel] Using generated seed: N (set SENTINEL_SEED to pin this run)
shows the value to reuse for reproduction.
Collects results, merges partitions, generates reports, and applies threshold judgment.
| Parameter | Type | Env Var | Default | Description |
|---|---|---|---|---|
threshold |
double | - | - | Minimum mutation score (0.0-100.0). Requires thresholdAction |
thresholdAction |
String | - | - | Action on failure: FAILURE or UNSTABLE. Requires threshold |
partitionTotal |
int | SENTINEL_PARTITION_TOTAL |
- | Number of partitions to collect and merge. Overrides the env var. |
sourceDir |
String | SENTINEL_SOURCE_DIR |
. |
Source directory for HTML reports |
outputDir |
String | SENTINEL_OUTPUT_DIR |
sentinel-report |
Report output directory |
sentinelPath |
String | SENTINEL_PATH |
sentinel |
Path to sentinel executable |
Behavior:
- If
SENTINEL_PARTITION_TOTALis set: unstashes all partition results, runssentinel --merge-partition, then generates reports. - If not set: unstashes a single result and generates reports directly.
- Before unstash/merge/report, the plugin recreates only its default or auto-assigned working directories to avoid stale files from earlier builds.
- Threshold judgment: if score < threshold, sets build result to
FAILUREorUNSTABLE.
All sentinel CLI options can be configured via SENTINEL_* environment variables in the pipeline environment block:
Environment variables are one of three ways to configure sentinel (the others are a repo
sentinel.yamland step parameters). They are most useful for sharing config across thematrixrecipe or run-scoped values likeSENTINEL_SEED. With the single-source recipe,sentinel.yamlor step parameters mean noSENTINEL_*variables are required.
| Variable | sentinel CLI Option | Type |
|---|---|---|
SENTINEL_BUILD_COMMAND |
--build-command |
String (required) |
SENTINEL_TEST_COMMAND |
--test-command |
String (required) |
SENTINEL_TEST_RESULT_DIR |
--test-result-dir |
String (required) |
SENTINEL_PARTITION_TOTAL |
--partition (denominator) |
Integer |
SENTINEL_SEED |
--seed |
Long |
SENTINEL_SOURCE_DIR |
--source-dir |
String |
SENTINEL_COMPILE_DB_DIR |
--compiledb-dir |
String |
SENTINEL_TIMEOUT |
--timeout |
Integer (seconds) |
SENTINEL_FROM |
--from |
String (revision) |
SENTINEL_UNCOMMITTED |
--uncommitted |
true / false |
SENTINEL_PATTERNS |
--pattern (repeated) |
Comma-separated |
SENTINEL_EXTENSIONS |
--extension (repeated) |
Comma-separated |
SENTINEL_GENERATOR |
--generator |
uniform, random, weighted |
SENTINEL_MUTANTS_PER_LINE |
--mutants-per-line |
Integer |
SENTINEL_OPERATORS |
--operator (repeated) |
Comma-separated |
SENTINEL_LIMIT |
--limit |
Integer |
SENTINEL_LCOV_TRACEFILES |
--lcov-tracefile (repeated) |
Comma-separated |
SENTINEL_CONFIG |
--config |
String (path) |
SENTINEL_CLEAN |
--clean |
true / false |
SENTINEL_DRY_RUN |
--dry-run |
true / false |
SENTINEL_VERBOSE |
--verbose |
true / false |
SENTINEL_WORKSPACE |
--workspace |
String |
SENTINEL_OUTPUT_DIR |
--output-dir |
String |
SENTINEL_PATH |
sentinel executable path | String |
- A malformed value for a known variable fails the build with a clear
message naming the variable — for example
SENTINEL_TIMEOUT must be an integer, got: '2h'. Bad values are never silently ignored. - An unrecognized
SENTINEL_*variable (usually a typo such asSENTINEL_TIMOUT) is reported as a warning and ignored; it does not fail the build, so unrelatedSENTINEL_-prefixed variables stay safe. - An empty or whitespace-only value counts as unset, so
SENTINEL_WORKSPACE = ''falls back to the plugin default rather than pointing sentinel at a directory named"". Numeric values are trimmed, soSENTINEL_TIMEOUT = ' 300 'parses. - Both steps validate before doing any work. A bad
thresholdor a misspelledthresholdActionfails immediately, not after the partitions have been collected and merged.
After a mutation test run completes, the plugin provides:
- Build page summary — a compact card on the build page showing overall mutation score with a stacked bar (killed/survived/skipped)
- Mutation Report — a detailed tabbed report page accessible via the build sidebar:
- Overview tab: score distribution (killed/survived/skipped) and mutator type distribution (donut charts)
- Files tab: per-file scores with inline progress bars
- Mutations tab: full mutation detail table with status/file filtering; the Reason column shows the killing test for killed mutations, or why a skipped mutation could not be evaluated
- Mutation Score Trend — a chart on the project page showing killed/survived/skipped counts as stacked bars per build, with the mutation score overlaid as a line on a second axis. The same chart appears in compact form on the job page (drag its bottom-right corner to resize; the chosen size is remembered per job in your browser) and full size under Sentinel Trend Report. The trend stays available as long as any of the last 25 builds has results, even if the most recent build failed before reaching
sentinelReport.
All charts are rendered using ECharts via the Jenkins echarts-api plugin. The report pages contain no inline JavaScript, so they render correctly on Jenkins instances that enforce a script-src 'self' Content-Security-Policy on UI pages (as recent Jenkins releases do).
Each mutation is classified as one of three statuses, used consistently across the summary, the Mutations tab filter, and the score:
- KILLED — a test detected the mutation (good).
- SURVIVED — no test detected the mutation; a test gap to address.
- SKIPPED — the mutation could not be evaluated (build failure, timeout, or runtime error).
The mutation score is killed / (killed + survived) × 100. SKIPPED mutations are excluded from the denominator, so they do not lower the score.
The View HTML Report link serves sentinel's generated HTML from the build's archived report directory. The sentinel report is a self-contained page that renders itself with inline JavaScript, so the plugin serves it under a sandboxing Content-Security-Policy that allows the report's own inline scripts and styles while isolating the page in an opaque origin (sandbox without allow-same-origin) — the report cannot access Jenkins cookies, APIs, or the parent page.
To use a different policy, set one of these system properties (the first that is set wins):
| System property | Scope |
|---|---|
io.jenkins.plugins.sentinel.reportCsp |
This report only |
hudson.model.DirectoryBrowserSupport.CSP |
Jenkins-wide, shared with workspace/artifact browsing |
Setting an empty value disables the header entirely. Use the plugin-scoped property when you want to tighten the Jenkins-wide policy without blanking this report.
In Manage Jenkins > System, you can set the default sentinel executable path. This is used when neither sentinelPath step parameter nor SENTINEL_PATH environment variable is specified.
When threshold and thresholdAction are set on sentinelReport:
- If the mutation score is below the threshold, the build result is set to
FAILUREorUNSTABLEdepending onthresholdAction. - If the mutation score meets or exceeds the threshold, the build result is not affected.
- If neither is set, the mutation score is reported but does not affect the build result.
The two must be set together. Setting only threshold leaves nothing
to act on, and setting only thresholdAction leaves nothing to compare
against — either alone is a gate that silently never fires, so the step
fails instead of passing quietly. threshold is also range-checked
(0.0–100.0) up front, before any partition is collected.
A mutant is SKIPPED when it could not be evaluated at all (build
failure, timeout, runtime error). Skipped mutants are excluded from the
score's denominator, so a run in which every mutant was skipped scores
0.0% by definition — and would trip any threshold. That is not a test
gap, so the plugin says so explicitly in the build log:
[Sentinel] WARNING: all 240 mutants were skipped, so no mutant was actually
evaluated. The score is 0.0% by definition here, not a test gap - check the
build/test commands for failures, timeouts, or runtime errors.
Mutation testing is long-running: sentinelRun executes your build and test commands once per mutant. A few practices keep builds cancellable and prevent stuck jobs:
-
Wrap the steps in
timeout {}. This is the recommended way to bound a run that never finishes (for example, a mutant that makes a test deadlock). On abort or timeout the plugin kills the running sentinel process (and its child build/test processes) on the agent, so cancellation is reliable while the agent connection is alive:stage('Mutation Test') { steps { checkout scm timeout(time: 2, unit: 'HOURS') { sentinelRun() } } }
SENTINEL_TIMEOUTis not a substitute — it is sentinel's per-mutant--timeout, not a wall-clock limit on the whole step. -
Size executors and agents for the workload. Each
sentinelRun/sentinelReportholds the node's executor for the entire run. With too few executors on a label, one long partition can leave other builds queued (and appearing not to start) until it finishes. Provision enough executors/agents per label. -
Keep the remoting ping enabled. On a remote agent, cancellation relies on the controller↔agent connection. If that connection dies silently (spot reclaim, network blip, paused VM), Jenkins detects it via the remoting ping (
hudson.slaves.ChannelPinger). Do not disable it, so a dead connection is reaped and the step can be aborted. -
Avoid restarting the controller mid-run. These steps do not resume across a controller restart — a restart while a run is in progress fails that step, and a sentinel process already launched on an agent may keep running. Prefer Jenkins' safe restart (which waits for running steps), or abort the build first.
# Development environment — opens a bash shell with JDK 17 + Maven
docker build --target dev -t sentinel-dev .
docker run -it -v $(pwd):/workspace sentinel-dev
# Build and test inside Docker
docker build --target build -t sentinel-build .
# Build with static analysis
docker build --target build --build-arg MAVEN_GOALS="clean verify -Pstatic-analysis" .
# Extract .hpi artifact from the package stage (a -SNAPSHOT build)
docker build -t sentinel .
docker cp $(docker create sentinel):/opt/sentinel.hpi .
# Same, but producing a release artifact
docker build --build-arg MAVEN_GOALS="-Dchangelist= clean verify" -t sentinel .
docker cp $(docker create sentinel):/opt/sentinel.hpi .# Requires Java 17+ and Maven 3.9+
mvn clean verify
# With static analysis (Checkstyle, SpotBugs, PMD, Error Prone, etc.)
mvn clean verify -Pstatic-analysisAn ordinary build produces a -SNAPSHOT artifact (0.1.0-SNAPSHOT), so it can
never be confused with a released .hpi.
The version uses Maven's CI-friendly form, ${revision}${changelist}. The
suffix is dropped on the command line rather than by editing pom.xml:
# Produces a bare 0.1.0 artifact
mvn -Dchangelist= clean verify -Pstatic-analysis
# Record the release tag in the artifact's SCM metadata (optional)
mvn -Dchangelist= -DscmTag=0.1.0 clean verifyTo start a new release cycle, bump the revision property in pom.xml — not
the <version> element, which stays as ${revision}${changelist}.
Note that flatten-maven-plugin is not enabled, so the ${revision} literal
survives into the POM embedded in the artifact. This is harmless here because
the plugin is distributed as an .hpi (whose Plugin-Version manifest entry
holds the resolved version) and never published to a Maven repository. Enable
the parent POM's might-produce-incrementals profile if that ever changes.
You can launch a local Jenkins instance with the plugin pre-installed:
mvn hpi:runThis starts Jenkins at http://localhost:8080/jenkins/ with the plugin loaded. Create a Pipeline job to test sentinelRun and sentinelReport steps interactively. To use a different port:
mvn hpi:run -Dport=9090The project source code is available under the MIT license. See LICENSE.