Chapter 28Lesson 02~330 minutes

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.

Provider-neutral launcherNearest-rank p95JTL gateArtifacts alwaysThree CI adapters

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/
Ceiling: 100 target requests/run. The launcher owns the localhost fixture and stops it. Abort on non-loopback target, version mismatch, >100 target events, missing artifacts or generator/runner saturation.

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?

What changes before the passing rerun?

Why set GitLab artifacts when=always?

Where should threshold logic live?

What does engine exit 20 mean?

Next lesson

Choose gate design deliberately

Lesson 3 compares cadence, absolute/baseline policy, environments, evaluator architecture and blocking severity.

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.