CI/CD Performance Gates in GitHub Actions, GitLab CI, and Jenkins: Guided Hands-On Workflow
The same command should work locally and in CI. The launcher starts a synthetic fixture, verifies JMeter 5.6.3, runs one bounded CLI plan, generates the dashboard, evaluates JTL/target evidence and always writes a structured output directory.
Learning objectives
- Execute a bounded hands-on workflow for CI/CD Performance Gates in GitHub Actions, GitLab CI, and Jenkins using the course's current runtime and authorized local or synthetic resources.
- Build and verify the concrete lab artifacts step by step instead of treating configuration snippets as isolated examples.
- Preserve the JTL, jmeter.log, target, generator, and configuration evidence required by the workflow before interpreting results.
- Distinguish configured state from achieved behavior, and stop when safety, count, environment, or generator-validity conditions are not met.
- Explain how the completed workflow prepares the configuration and trade-off analysis in the next lesson.
1. Files
p28-lab/
├── fixtures/ci_gate_fixture.py
├── plans/ci-gate.jmx
├── config/ci-gate.properties
├── policy/slo.json
├── policy/baseline.json
├── tools/provision_jmeter.py
├── tools/evaluate_gate.py
├── tools/run_performance_gate.py
└── results/
2. Synthetic target modes
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import argparse, json, re, threading, time
FIXTURE_VERSION = "prompt28-ci-gate-fixture-v1"
SAFE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
PROFILES = {
"baseline": {"delay_ms": 40, "error_every": 0},
"degraded": {"delay_ms": 180, "error_every": 0},
"error": {"delay_ms": 40, "error_every": 10},
}
lock = threading.Lock()
state = {"requests": 0, "errors": 0}
event_log = None
mode = "baseline"
def now_ms():
return int(time.time() * 1000)
def snapshot():
with lock:
p = PROFILES[mode]
return {
"mode": mode, "delay_ms": p["delay_ms"],
"error_every": p["error_every"],
"requests": state["requests"], "errors": state["errors"]
}
def write_event(event):
if event_log is None:
return
with lock:
with event_log.open("a", encoding="utf-8") as h:
h.write(json.dumps(event, sort_keys=True) + "\n")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_json(self, status, payload):
raw = json.dumps(payload, sort_keys=True).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("X-Fixture-Version", FIXTURE_VERSION)
self.send_header("X-Fixture-Mode", mode)
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
started = now_ms()
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_json(200, {"status": "ok", "epoch_ms": now_ms(), "state": snapshot()})
return
if parsed.path == "/stats":
self.send_json(200, {"epoch_ms": now_ms(), "state": snapshot()})
return
if parsed.path != "/work":
self.send_json(404, {"status": "not_found"})
return
q = parse_qs(parsed.query)
run_id = q.get("run_id", [""])[0]
thread_id = q.get("thread", [""])[0]
seq_raw = q.get("seq", [""])[0]
if not SAFE.fullmatch(run_id) or not SAFE.fullmatch(thread_id):
self.send_json(400, {"status": "invalid_metadata"})
return
try:
seq = int(seq_raw)
except ValueError:
self.send_json(400, {"status": "invalid_seq"})
return
with lock:
state["requests"] += 1
request_no = state["requests"]
profile = dict(PROFILES[mode])
time.sleep(profile["delay_ms"] / 1000.0)
should_error = profile["error_every"] > 0 and request_no % profile["error_every"] == 0
status = 500 if should_error else 200
if should_error:
with lock:
state["errors"] += 1
self.send_json(status, {
"status": "synthetic_error" if should_error else "ok",
"run_id": run_id, "thread": thread_id, "seq": seq,
"mode": mode, "configured_delay_ms": profile["delay_ms"],
"request_no": request_no
})
ended = now_ms()
write_event({
"ts_ms": ended, "operation": "work", "status": status,
"run_id": run_id, "thread": thread_id, "seq": seq,
"mode": mode, "configured_delay_ms": profile["delay_ms"],
"service_wall_ms": ended - started
})
def log_message(self, format, *args):
return
def main():
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8028)
p.add_argument("--mode", choices=sorted(PROFILES), default="baseline")
p.add_argument("--log", required=True)
args = p.parse_args()
global event_log, mode
mode = args.mode
event_log = Path(args.log).resolve()
event_log.parent.mkdir(parents=True, exist_ok=True)
event_log.write_text("", encoding="utf-8")
print(f"fixture_version={FIXTURE_VERSION}", flush=True)
print(f"mode={mode}", flush=True)
print(f"listen=http://{args.host}:{args.port}", flush=True)
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()
Baseline=40 ms/no errors; degraded=180 ms/no errors; optional error mode returns a 500 every tenth request. The checkpoint uses degraded then baseline so only latency changes.
3. Provider-neutral JMeter provisioner
from pathlib import Path
import argparse, hashlib, os, shutil, tarfile, tempfile, urllib.request
VERSION = "5.6.3"
SHA512 = "5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083"
URL = "https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-5.6.3.tgz"
def safe_extract(tar, dest):
root = dest.resolve()
for member in tar.getmembers():
target = (dest / member.name).resolve()
if root != target and root not in target.parents:
raise RuntimeError("unsafe archive path")
tar.extractall(dest)
def main():
p = argparse.ArgumentParser()
p.add_argument("--dest", default=".tools")
args = p.parse_args()
dest = Path(args.dest).resolve()
install = dest / f"apache-jmeter-{VERSION}"
binary = install / "bin" / ("jmeter.bat" if os.name == "nt" else "jmeter")
if binary.exists():
print(f"already_present={install}")
return
dest.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as td:
archive = Path(td) / "apache-jmeter.tgz"
with urllib.request.urlopen(URL, timeout=60) as r, archive.open("wb") as out:
shutil.copyfileobj(r, out)
digest = hashlib.sha512(archive.read_bytes()).hexdigest()
if digest.lower() != SHA512.lower():
raise SystemExit(f"SHA-512 mismatch: {digest}")
with tarfile.open(archive, "r:gz") as tar:
safe_extract(tar, dest)
if not binary.exists():
raise SystemExit("JMeter binary missing after extraction")
print(f"installed={install}")
print(f"sha512={SHA512}")
if __name__ == "__main__":
main()
The helper downloads exact Apache JMeter 5.6.3, verifies Apache's
SHA-512 and extracts into .tools. Java 17 is a runner
prerequisite.
4. SLO and optional baseline
policy/slo.json:
{
"schema_version": 1,
"label": "Work",
"expected_samples": 100,
"max_p95_ms": 120,
"max_error_rate_pct": 1.0,
"percentile_method": "nearest-rank"
}
policy/baseline.json:
{
"schema_version": 1,
"label": "Work",
"approved_reference": "example-reviewed-baseline",
"p95_ms": 60,
"max_p95_regression_pct": 30.0,
"note": "Optional scheduled regression policy; not enabled in the checkpoint."
}
The checkpoint does not enable the baseline, so degraded mode has one gate cause: p95 above 120 ms.
5. JMX/result design
config/ci-gate.properties:
target.host=127.0.0.1
target.port=8028
threads=5
loops=20
pacing.ms=100
connect.timeout.ms=500
response.timeout.ms=2000
jmeter.httpsampler=HttpClient4
httpclient4.retrycount=0
jmeter.save.saveservice.output_format=csv
jmeter.save.saveservice.print_field_names=true
jmeter.save.saveservice.timestamp_format=ms
jmeter.save.saveservice.time=true
jmeter.save.saveservice.label=true
jmeter.save.saveservice.response_code=true
jmeter.save.saveservice.response_message=true
jmeter.save.saveservice.thread_name=true
jmeter.save.saveservice.successful=true
jmeter.save.saveservice.bytes=true
jmeter.save.saveservice.sent_bytes=true
jmeter.save.saveservice.thread_counts=true
jmeter.save.saveservice.latency=true
jmeter.save.saveservice.connect_time=true
jmeter.save.saveservice.assertion_results_failure_message=true
jmeter.save.saveservice.response_data=false
jmeter.save.saveservice.response_data.on_error=false
jmeter.save.saveservice.samplerData=false
jmeter.save.saveservice.responseHeaders=false
jmeter.save.saveservice.requestHeaders=false
jmeter.save.saveservice.url=false
jmeter.reportgenerator.overall_granularity=2000
jmeter.reportgenerator.aggregate_rpt_pct1=90
jmeter.reportgenerator.aggregate_rpt_pct2=95
jmeter.reportgenerator.aggregate_rpt_pct3=99
JMX tree:
Test Plan
├── HTTP Request Defaults
│ host=${__P(target.host,127.0.0.1)}
│ port=${__P(target.port,8028)}
│ implementation=HttpClient4
└── Thread Group
threads=${__P(threads,5)}
loops=${__P(loops,20)}
Action after Sampler error=Continue
├── Counter -> SEQ (per user)
└── HTTP Request — Work
GET /work
run_id=${__P(run.id,p28-local)}
thread=T${__threadNum}
seq=${SEQ}
Use KeepAlive=checked
├── Constant Timer ${__P(pacing.ms,100)} ms
└── Response Assertion: HTTP response code = 200
The Constant Timer paces starts/iterations but is not target response time. The Response Assertion turns HTTP error responses into unsuccessful samples. Dashboard generation is diagnostic; the gate uses raw CSV.
6. Auditable gate evaluator
import argparse, csv, json, math
from collections import Counter
from pathlib import Path
EXIT_PASS = 0
EXIT_SLO_FAIL = 10
EXIT_INVALID_RUN = 11
def nearest_rank(values, pct):
data = sorted(values)
if not data:
return 0
return data[max(1, math.ceil(len(data) * pct / 100.0)) - 1]
def main():
p = argparse.ArgumentParser()
p.add_argument("--jtl", required=True)
p.add_argument("--target-events", required=True)
p.add_argument("--run-id", required=True)
p.add_argument("--slo", required=True)
p.add_argument("--baseline")
p.add_argument("--out", required=True)
args = p.parse_args()
slo = json.loads(Path(args.slo).read_text(encoding="utf-8"))
expected = int(slo["expected_samples"])
rows = list(csv.DictReader(Path(args.jtl).open(newline="", encoding="utf-8")))
rows = [r for r in rows if r.get("label") == slo["label"]]
elapsed = [int(float(r["elapsed"])) for r in rows]
failures = sum(r.get("success", "").lower() != "true" for r in rows)
p95 = nearest_rank(elapsed, 95)
error_rate = (100.0 * failures / len(rows)) if rows else 100.0
events = [
json.loads(line)
for line in Path(args.target_events).read_text(encoding="utf-8").splitlines()
if line.strip()
]
target = [e for e in events if e.get("operation") == "work" and e.get("run_id") == args.run_id]
validity = []
if len(rows) != expected:
validity.append(f"JTL samples {len(rows)} != expected {expected}")
if len(target) != expected:
validity.append(f"target events {len(target)} != expected {expected}")
slo_failures = []
if p95 > float(slo["max_p95_ms"]):
slo_failures.append(f"p95 {p95}ms > {slo['max_p95_ms']}ms")
if error_rate > float(slo["max_error_rate_pct"]):
slo_failures.append(f"error rate {error_rate:.3f}% > {slo['max_error_rate_pct']}%")
baseline_check = None
if args.baseline:
baseline = json.loads(Path(args.baseline).read_text(encoding="utf-8"))
allowed = float(baseline["p95_ms"]) * (1 + float(baseline["max_p95_regression_pct"]) / 100.0)
baseline_check = {
"approved_reference": baseline.get("approved_reference"),
"baseline_p95_ms": baseline["p95_ms"],
"max_regression_pct": baseline["max_p95_regression_pct"],
"allowed_p95_ms": round(allowed, 3),
"current_p95_ms": p95
}
if p95 > allowed:
slo_failures.append(f"p95 {p95}ms > regression allowance {allowed:.3f}ms")
if validity:
status, rc = "INVALID_RUN", EXIT_INVALID_RUN
elif slo_failures:
status, rc = "SLO_FAIL", EXIT_SLO_FAIL
else:
status, rc = "PASS", EXIT_PASS
if rows:
start = min(int(r["timeStamp"]) for r in rows)
end = max(int(r["timeStamp"]) + int(float(r["elapsed"])) for r in rows)
span_s = max((end - start) / 1000.0, 0.001)
else:
span_s = 0.0
result = {
"schema_version": 1,
"status": status,
"exit_code": rc,
"run_id": args.run_id,
"policy": slo,
"metrics": {
"samples": len(rows),
"failures": failures,
"error_rate_pct": round(error_rate, 4),
"p95_ms_nearest_rank": p95,
"span_s": round(span_s, 3),
"achieved_rps": round(len(rows) / span_s, 3) if span_s else 0.0,
"target_events": len(target),
"target_failures": sum(int(e.get("status", 0)) >= 400 for e in target),
"target_modes": dict(Counter(e.get("mode") for e in target))
},
"validity_failures": validity,
"slo_failures": slo_failures,
"baseline_check": baseline_check
}
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result, indent=2))
raise SystemExit(rc)
if __name__ == "__main__":
main()
Order matters: first require exact JTL/target counts, then calculate p95/error rate, then apply absolute/optional baseline policy. An incomplete run must not pass because its partial p95 looks fast.
7. Portable launcher
import argparse, hashlib, json, os, shutil, subprocess, sys, time, urllib.request
from pathlib import Path
EXIT_ENGINE_FAIL = 20
EXIT_EVALUATOR_FAIL = 21
EXIT_PREFLIGHT_FAIL = 22
def sha256(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def run_cmd(executable, args, **kwargs):
exe = str(executable)
if os.name == "nt" and exe.lower().endswith((".bat", ".cmd")):
cmdline = subprocess.list2cmdline([exe] + [str(x) for x in args])
return subprocess.run(["cmd.exe", "/d", "/s", "/c", cmdline], **kwargs)
return subprocess.run([exe] + [str(x) for x in args], **kwargs)
def resolve_jmeter(explicit=None):
if explicit:
return Path(explicit).resolve()
if os.environ.get("JMETER_BIN"):
return Path(os.environ["JMETER_BIN"]).resolve()
if os.environ.get("JMETER_HOME"):
return (Path(os.environ["JMETER_HOME"]) / "bin" / ("jmeter.bat" if os.name == "nt" else "jmeter")).resolve()
found = shutil.which("jmeter.bat" if os.name == "nt" else "jmeter")
if found:
return Path(found).resolve()
raise RuntimeError("JMeter not found; set JMETER_HOME or JMETER_BIN")
def wait_health(url, timeout_s=10):
deadline = time.monotonic() + timeout_s
last = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as r:
return json.loads(r.read())
except Exception as exc:
last = str(exc)
time.sleep(0.2)
raise RuntimeError(f"fixture health timeout: {last}")
def main():
p = argparse.ArgumentParser()
p.add_argument("--fixture-mode", choices=["baseline", "degraded", "error"], required=True)
p.add_argument("--run-id", required=True)
p.add_argument("--out", required=True)
p.add_argument("--jmx", default="plans/ci-gate.jmx")
p.add_argument("--properties", default="config/ci-gate.properties")
p.add_argument("--slo", default="policy/slo.json")
p.add_argument("--baseline")
p.add_argument("--jmeter-bin")
args = p.parse_args()
root = Path.cwd()
out = Path(args.out).resolve()
out.mkdir(parents=True, exist_ok=True)
dashboard = out / "dashboard"
if dashboard.exists():
shutil.rmtree(dashboard)
doc = {
"schema_version": 1, "run_id": args.run_id,
"fixture_mode": args.fixture_mode,
"configured_threads": 5, "configured_loops": 20,
"configured_samples": 100,
"engine_exit_code": None, "gate_exit_code": None,
"final_exit_code": None
}
jmx = Path(args.jmx).resolve()
props = Path(args.properties).resolve()
slo = Path(args.slo).resolve()
fixture_script = (root / "fixtures" / "ci_gate_fixture.py").resolve()
evaluator_script = (root / "tools" / "evaluate_gate.py").resolve()
manifest_paths = [jmx, props, slo, fixture_script, evaluator_script, Path(__file__).resolve()]
if args.baseline:
manifest_paths.append(Path(args.baseline).resolve())
(out / "input-manifest.json").write_text(json.dumps({
"files": [{"path": str(x), "sha256": sha256(x)} for x in manifest_paths]
}, indent=2), encoding="utf-8")
fixture_console = (out / "fixture-console.log").open("w", encoding="utf-8")
fixture = None
started = time.monotonic()
try:
try:
jmeter = resolve_jmeter(args.jmeter_bin)
ver = run_cmd(jmeter, ["-v"], capture_output=True, text=True, timeout=20)
ver_text = (ver.stdout or "") + (ver.stderr or "")
if ver.returncode != 0 or "5.6.3" not in ver_text:
raise RuntimeError("expected Apache JMeter 5.6.3")
java = subprocess.run(["java", "-version"], capture_output=True, text=True, timeout=20)
java_text = (java.stdout or "") + (java.stderr or "")
(out / "versions.txt").write_text(
"JMeter:\n" + ver_text + "\nJava:\n" + java_text, encoding="utf-8"
)
fixture = subprocess.Popen(
[sys.executable, str(fixture_script),
"--host", "127.0.0.1", "--port", "8028",
"--mode", args.fixture_mode,
"--log", str(out / "target-events.jsonl")],
stdout=fixture_console, stderr=subprocess.STDOUT, text=True
)
health = wait_health("http://127.0.0.1:8028/health")
(out / "preflight.json").write_text(json.dumps(health, indent=2), encoding="utf-8")
except Exception as exc:
doc["preflight_error"] = str(exc)
doc["final_exit_code"] = EXIT_PREFLIGHT_FAIL
return EXIT_PREFLIGHT_FAIL
jargs = [
"-n", "-t", str(jmx), "-q", str(props),
f"-Jrun.id={args.run_id}",
"-Jtarget.host=127.0.0.1", "-Jtarget.port=8028",
"-l", str(out / "results.jtl"),
"-j", str(out / "jmeter.log"),
"-e", "-o", str(dashboard)
]
with (out / "jmeter-console.log").open("w", encoding="utf-8") as console:
engine = run_cmd(
jmeter, jargs, stdout=console, stderr=subprocess.STDOUT,
text=True, timeout=60
)
doc["engine_exit_code"] = engine.returncode
try:
with urllib.request.urlopen("http://127.0.0.1:8028/stats", timeout=2) as r:
summary = json.loads(r.read())
(out / "target-summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
except Exception as exc:
doc["target_summary_error"] = str(exc)
if engine.returncode != 0:
doc["final_exit_code"] = EXIT_ENGINE_FAIL
return EXIT_ENGINE_FAIL
eargs = [
str(evaluator_script),
"--jtl", str(out / "results.jtl"),
"--target-events", str(out / "target-events.jsonl"),
"--run-id", args.run_id,
"--slo", str(slo),
"--out", str(out / "gate.json")
]
if args.baseline:
eargs += ["--baseline", str(Path(args.baseline).resolve())]
with (out / "gate-console.log").open("w", encoding="utf-8") as gc:
gate = subprocess.run([sys.executable] + eargs, stdout=gc, stderr=subprocess.STDOUT, text=True, timeout=20)
doc["gate_exit_code"] = gate.returncode
if gate.returncode in (0, 10, 11):
doc["final_exit_code"] = gate.returncode
return gate.returncode
doc["final_exit_code"] = EXIT_EVALUATOR_FAIL
return EXIT_EVALUATOR_FAIL
finally:
if fixture is not None:
fixture.terminate()
try:
fixture.wait(timeout=3)
except subprocess.TimeoutExpired:
fixture.kill()
fixture.wait(timeout=3)
fixture_console.close()
doc["duration_s"] = round(time.monotonic() - started, 3)
(out / "launcher.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
if __name__ == "__main__":
raise SystemExit(main())
The launcher preserves version/input manifests, target events,
JMeter console/JTL/jmeter.log/dashboard, target
summary, gate output and separate engine/gate/final codes.
8. Provision locally
python .\tools\provision_jmeter.py --dest .tools
$env:JMETER_HOME = (Resolve-Path ".\.tools\apache-jmeter-5.6.3").Path
& "$env:JMETER_HOME\bin\jmeter.bat" -v
java -version
9. Deliberately fail only the latency SLO
python .\tools\run_performance_gate.py `
--fixture-mode degraded `
--run-id p28-degraded `
--out .\results\p28-degraded
$Exit = $LASTEXITCODE
Write-Host "Expected gate exit: 10; actual: $Exit"
Expected: 100 JTL rows, 100 target events, 0% errors, p95 around 180 ms, engine exit 0, gate/final exit 10, HTML dashboard present.
10. Restore target behavior; do not relax policy
python .\tools\run_performance_gate.py `
--fixture-mode baseline `
--run-id p28-baseline `
--out .\results\p28-baseline
$Exit = $LASTEXITCODE
Write-Host "Expected gate exit: 0; actual: $Exit"
Same 5×20 workload and same policy; only fixture delay changes 180→40 ms. Preserve both result directories.
11. GitHub Actions adapter
name: jmeter-smoke-gate
on:
pull_request:
permissions:
contents: read
jobs:
performance-gate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Provision JMeter 5.6.3
run: python3 tools/provision_jmeter.py --dest .tools
- name: Run portable performance gate
env:
JMETER_HOME: ${{ github.workspace }}/.tools/apache-jmeter-5.6.3
run: >
python3 tools/run_performance_gate.py
--fixture-mode baseline
--run-id pr-${{ github.run_id }}
--out results/p28-ci
- name: Upload performance evidence
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jmeter-performance-evidence
path: results/p28-ci/
retention-days: 7
always() ensures the evidence upload step still runs
after a blocking nonzero gate.
12. GitLab CI adapter
stages:
- test
performance_gate:
stage: test
timeout: 10m
script:
- java -version
- python3 tools/provision_jmeter.py --dest .tools
- export JMETER_HOME="$CI_PROJECT_DIR/.tools/apache-jmeter-5.6.3"
- python3 tools/run_performance_gate.py --fixture-mode baseline --run-id "gitlab-$CI_PIPELINE_ID" --out results/p28-ci
artifacts:
when: always
expire_in: 7 days
paths:
- results/p28-ci/
GitLab artifact upload is success-only by default;
when: always is required for performance-failure
triage.
13. Jenkins Declarative adapter
pipeline {
agent { label 'java17-python3' }
options {
timeout(time: 10, unit: 'MINUTES')
}
stages {
stage('Performance gate') {
steps {
sh 'java -version'
sh 'python3 tools/provision_jmeter.py --dest .tools'
sh '''
export JMETER_HOME="$WORKSPACE/.tools/apache-jmeter-5.6.3"
python3 tools/run_performance_gate.py \
--fixture-mode baseline \
--run-id "jenkins-${BUILD_NUMBER}" \
--out results/p28-ci
'''
}
}
}
post {
always {
archiveArtifacts artifacts: 'results/p28-ci/**',
allowEmptyArchive: true,
fingerprint: true
}
}
}
The sh step propagates nonzero gate status;
post/always archives evidence even when the stage
fails.
14. Challenge
A shared staging gate is noisy. Should the portable launcher retry three times until one run passes?
No. First choose the correct environment/cadence: isolate a blocking PR smoke gate or move the noisy shared suite to scheduled/informational mode. Automatic retries change workload/statistical policy and can hide regressions.
Knowledge check
Expected degraded result?
Engine 0, valid 100/100 samples, p95 above 120 ms, gate exit 10.
What changes before the passing rerun?
Only fixture mode/delay; JMX, workload, runtime and SLO remain unchanged.
Why set GitLab artifacts when=always?
Failure evidence otherwise may not be uploaded.
Where should threshold logic live?
In the common evaluator, not duplicated in provider YAML/Groovy or JMX.
What does engine exit 20 mean?
JMeter execution failed, distinct from a valid application SLO failure.
Official references and version notes
- Apache JMeter downloads — JMeter 5.6.3 and Java 8+ requirement.
- JMeter execution guidance — GUI for authoring/debug and CLI/non-GUI for load.
-
JMeter Dashboard Report
—
-e -o/-g -oand dashboard CSV requirements. -
GitHub Actions build/test examples
— artifact upload with
always()after test failure. -
GitHub setup-java
— stable Java setup action; example uses
v5with Temurin 17. -
GitLab CI YAML reference
—
artifacts:when: alwayssemantics. -
Jenkins Declarative Pipeline syntax
—
post/always, timeout, agent behavior. -
Jenkins tests and artifacts
—
archiveArtifactsin an always-run post block.
Statements were rechecked against current primary documentation on
2026-09-05. The mandatory runtime is
Apache JMeter 5.6.3 with Java 17; no third-party
JMeter plugin is required. The provider-neutral provisioner
verifies Apache's published SHA-512 before installing JMeter.
Meaningful execution stays in CLI mode and produces the HTML
dashboard using -e -o. The gate evaluator is
Python-standard-library only and calculates p95 from raw CSV JTL
using an explicitly documented nearest-rank method. The dashboard
remains diagnostic evidence rather than the policy parser.
GitHub's minimal pattern uses actions/checkout@v6,
stable actions/setup-java@v5 with Temurin 17, and
actions/upload-artifact@v4 guarded by
always(). GitLab explicitly sets
artifacts:when: always, and Jenkins archives in
Declarative post { always { ... } }. Provider
YAML/Groovy stays thin; performance formulas and exit
classification live in the shared launcher/evaluator.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.