Module 7: Analyzing Results And Ci

6. Running k6 in CI: the `load.yml` and the gate

Overview

Everything before —measure, export, detect a regression— is worth much more when it runs on its own, without anyone having to remember to launch it. That's continuous integration (CI): a pipeline that, on a trigger (a push, a scheduled time), runs steps automatically. In this lesson you put the load test in CI. On the k6 side, you write the .github/workflows/load.yml —presented as labeled content, because git and gh are never run here— that installs k6, runs k6 run with the module-5 thresholds as a gate, and uploads the JSON as an artifact. The central mechanism is the one you already know: if a threshold fails, k6 run exits with a nonzero code, the pipeline step fails, and that blocks the merge or the deploy. On the executable side, you set up that same mechanism in a real shell step: the regression check from lesson 4, whose exit code —with && and ||authorizes or blocks the deploy for real.

Connection to the module: this lesson opens the automating half. It takes the module-5 verdict (the threshold → exit code) and the lesson-4 check, and installs them in a pipeline. It reuses the thresholds (M5) as the gate, the k6 script (M2) as what's run, and the export (lesson 3) as the artifact. What comes next (lesson 7) answers the question this YAML leaves open: when to trigger this pipeline (the on: block).

The factory's automatic gatekeeper

In a well-run factory, no product goes out on the delivery truck without passing an automatic gatekeeper at the end of the line. The gatekeeper doesn't opine or get distracted: it has a rule ("the box must weigh the right amount and be sealed") and a physical gate. If the box complies, the gate opens and the box goes up on the truck. If it doesn't comply, the gate closes and the box is diverted for review. No one has to be watching; the gatekeeper acts on each box, at the speed of the line, without getting tired.

A CI pipeline is that line, and the performance gate is that gatekeeper. The load.yml describes the line (the steps); the threshold is the gatekeeper's rule; and the exit code is the physical gate. When the threshold fails, k6 run exits with code ≠ 0, the gate closes, and the code change is diverted —it isn't merged, it isn't deployed—. What makes this setup so powerful is that the gatekeeper is tireless and objective: it checks every change with the same rigor, at 3 in the morning or before an important launch, without giving in to haste. This is the gatekeeper you're going to build.

The load.yml (content)

Here's the GitHub Actions workflow that runs the load test. It's labeled content, faithful to GitHub Actions' and k6's documentation; in this environment git and gh are never run, so this YAML is to read and understand, not to run here:

# CONTENT (not run here): .github/workflows/load.yml
# Runs the k6 load test in CI with the thresholds as the gate.
# See grafana.com/docs/k6 and docs.github.com/actions.
name: load-test

on:
  schedule:
    - cron: "0 3 * * *"      # every night at 03:00 UTC (nightly)
  workflow_dispatch:          # and manually, when someone triggers it
  # (why NOT 'on: pull_request' -> that's lesson 7)

jobs:
  load:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Start the Reservo API (the target)
        run: |
          python3 reservo_server.py &   # in the background
          sleep 2                        # give it a moment to come up

      - name: Install k6
        uses: grafana/setup-k6-action@v1

      - name: Run the load test (the thresholds are the GATE)
        run: k6 run --out json=results.json load_test.js
        # If a threshold fails, `k6 run` exits with code 99 -> this step
        # fails -> the whole job fails -> the merge/deploy is blocked.

      - name: Save the JSON as an artifact
        if: always()               # upload it even if the previous step failed
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

Let's take it apart by the parts that matter:

  • on: — the triggers. Here, schedule (nightly, with a cron) and workflow_dispatch (manual). Deliberately not pull_request: the load test doesn't go on every PR, and the why is lesson 7.
  • Start the target. Before launching load you have to bring up the API that's going to be tested. In CI, the runner starts the Reservo server in the background (or brings up the real service it's tested against).
  • Install k6. The grafana/setup-k6-action@v1 step installs the k6 binary on the runner. (k6 is a Go binary; the CI runner does install it, unlike this guide's environment.)
  • k6 run with the gate. This is the heart. k6 run runs the script with its options.thresholds (M5). The decisive behavior: if a threshold fails, k6 run ends with exit code 99. Like any CI step, an exit code ≠ 0 marks the step —and with it the job— as failed. A failed job is what blocks the merge (if it's a required check) or stops the deploy. The --out json=results.json exports the result (lesson 3) for the next step.
  • upload-artifact with if: always(). Uploads the results.json as a downloadable artifact. The if: always() is important: it uploads it even if the previous step failed —exactly when the gate fails is when you most want the JSON to investigate why—.

The piece that turns this into a gate isn't any special configuration: it's that k6 run propagates its verdict as an exit code, and CI respects exit codes by design. The module-5 threshold wasn't decoration; it was the condition that, through the exit code, closes the gate.

The executable side: the exit code that decides the deploy

k6 and the YAML are content, but the mechanism —an exit code that authorizes or blocks— we can run for real, and it's identical. A CI step is, at bottom, a shell command whose success or failure is decided by its exit code. We can simulate the "gate step" with the regression check from lesson 4 and the operators && (run the next thing only if the previous succeeded) and || (run the next thing only if the previous failed):

What to expect — when the gate fails (there's a regression), the || fires and the deploy is blocked; the gate's exit code was 1:

$ python3.14 check_regression.py results_baseline.json results_actual.json 20 > /dev/null \
    && echo "DEPLOY: authorized" \
    || echo "DEPLOY: BLOCKED (the CI step failed, exit code $?)"
DEPLOY: BLOCKED (the CI step failed, exit code 1)

What to expect — when the gate passes (no regression), the && fires and the deploy is authorized:

$ python3.14 check_regression.py results_baseline.json results_baseline2.json 20 > /dev/null \
    && echo "DEPLOY: authorized" \
    || echo "DEPLOY: BLOCKED"
DEPLOY: authorized

That is, in miniature and actually executed, exactly what CI does. The check_regression.py is the analog of k6 run: it returns exit code 1 when the verdict is "I failed," and the shell —like the pipeline— reacts to that code. The && echo "authorized" || echo "BLOCKED" is the analog of "the job passed → deploy / the job failed → stop." In a real pipeline, that "BLOCKED" is GitHub marking the check red and not letting you merge. The logic is the same; only the scale changes.

k6 and the regression check, together in the pipeline

It's worth clarifying how the module's two tools coexist inside a single load.yml, because they catch different things (you saw it in lesson 4):

  • k6 run with thresholds is the absolute SLO gate: it fails if the p95 crosses the limit you promised (200 ms). It's the main step.
  • The regression check is the relative gate: it fails if the p95 got worse relative to the saved baseline, even if it's still below the SLO. It's added as an extra step that downloads the baseline's results.json (a previous artifact) and compares it with this run's.

A mature pipeline has both steps. Each propagates its own exit code, and either of the two failing puts the job red. That way the deploy is blocked whether you broke the SLO or degraded the performance without breaking it yet —the two safety nets, in the same gatekeeper—.

Common mistakes

Running the load test but ignoring its exit code. What happens: the pipeline runs k6 run, but the result is saved to a dashboard no one looks at, or the step is configured to "never fail" (continue-on-error: true). Why it happens: the test gets treated as a report, not a gate. How to detect it: if a regression can't put the build red, you don't have a gate, you have decoration. How to fix it: let the exit code of k6 run (or of the check) fail the step; the gate only protects if it has teeth.

Uploading the artifact only when the gate passes. What happens: the upload-artifact doesn't have if: always(), so when the gate fails —the most interesting case— the JSON isn't uploaded and you can't investigate. Why it happens: by default, a step doesn't run if the previous one failed. How to detect it: red builds with no artifact to diagnose. How to fix it: put if: always() on the upload step, to have the results.json especially when something failed.

Forgetting to start the target before running k6. What happens: k6 run starts but all the requests fail with "connection refused," because the API wasn't up. Why it happens: it's assumed the service is already running on the runner. How to detect it: a 100% error rate and an http_req_failed that fires the threshold for the wrong reason. How to fix it: add the step that starts the API (and waits for it to be ready) before the k6 run, as in the YAML above.

Exercises

Exercise 1 — What puts the build red? In the load.yml, say whether each situation fails the job (red build) or not. (a) A threshold p(95)<200 breaks and k6 run exits with 99. (b) The k6 run ends with all thresholds green (exit 0). (c) The upload-artifact step with if: always() runs after k6 run failed.

See solution
  • (a) It fails (red build). Exit code 99 ≠ 0 → the step fails → the job fails → merge/deploy blocked.
  • (b) It doesn't fail (green build). Exit code 0 → the step passes → the job continues.
  • (c) The upload step runs (because of the if: always()) and, if it uploads fine, has exit 0 —but the job was already marked as failed by the previous k6 run—. Uploading the artifact doesn't "rescue" the build: the job stays red because of the gate's failure. The if: always() only guarantees the JSON is available to investigate.

Exercise 2 — Read the shell. The command was python3.14 check_regression.py A B 20 && echo "authorized" || echo "BLOCKED". (a) What does it print if the check exits with 0? (b) And with 1? (c) How does this relate to "the CI job passed/failed"?

See solution
  • (a) authorized. Exit 0 = success → the && runs the first echo.
  • (b) BLOCKED. Exit 1 = failure → the && is skipped and the || runs the second echo.
  • (c) It's the same mechanism: in CI, "the job passed" is exit 0 and triggers the next step (deploy); "the job failed" is exit ≠ 0 and stops the pipeline (block). The shell's &&/|| is the one-line version of what the pipeline does between steps.

Exercise 3 — Add the regression step. The load.yml above only runs k6 run (the absolute gate). Describe, in prose, how you'd add the relative gate (the regression check) as another step. (a) What does that step need to be able to compare? (b) What happens if that step fails?

See solution
  • (a) It needs the baseline: the results.json of a previous reference run. The step would download it (for example, the artifact of the last run on main, or a baseline versioned in the repo), and then run check_regression.py baseline.json results.json 20 comparing it with the results.json k6 run just exported in this run.
  • (b) If the regression check fails (exit 1 because the p95 got worse than the limit), that step fails, and —like any failed step— it puts the job red and blocks the deploy. That way the pipeline has two gates: the absolute one (k6 run vs SLO) and the relative one (regression vs baseline). Either of the two failing stops the deploy.

Summary and next step

In this lesson you put the load test in CI. You wrote the .github/workflows/load.yml (content): check out the code, start the target, install k6, run k6 run with the thresholds as a gate, and upload the JSON as an artifact with if: always(). The mechanism that makes it a gate is the same as module 5's: k6 run exits with code 99 if a threshold fails, that exit code ≠ 0 marks the job as failed, and a failed job blocks the merge or the deploy —the factory's automatic gatekeeper—. On the executable side, you set up that mechanism for real with &&/||: the regression check that, according to its exit code, authorizes (DEPLOY: authorized) or blocks (DEPLOY: BLOCKED) the deploy. And you saw how the absolute gate (k6 run vs SLO) and the relative one (regression vs baseline) coexist in a single pipeline, each with its exit code.

Before moving on you should be able to: read a load.yml and point out which step is the gate and why; explain how an exit code ≠ 0 blocks the deploy; and describe how &&/|| reproduce that logic. What comes next, in lesson 7, is the question this YAML's on: leaves open: when to run the load test —why nightly and pre-release yes, and on every PR no—.

Resources

  • k6 — Running k6 in CI / GitHub Actions — the official reference for integrating k6 into a pipeline and how its exit code gates the build. The source of this lesson's content.
  • GitHub Actions — Workflow syntax — the syntax of the load.yml: on, jobs, steps, uses, run. How a step that exits with code ≠ 0 fails the job.
  • GitHub Actions — upload-artifact — how the results.json is uploaded as a downloadable artifact, with if: always() to have it even when the gate fails.
  • k6 — Thresholds — the reminder that a broken threshold makes k6 run exit with code 99; it's the piece that, via the exit code, turns the CI step into a gate.