Module 8: Project Load Test Reservo
7. The CI `load.yml` as content
Overview
A gate someone has to remember to run by hand protects nothing reliably. The capstone's last piece is to automate the test: put it in a continuous integration pipeline that runs it by itself and uses the threshold as a gate that blocks the deploy. In this lesson we write the .github/workflows/load.yml as content —faithful to GitHub Actions and to k6's official integration—: bring up the API, run k6 with the thresholds as the gate (if a threshold fails, k6 run exits with 99 and the step fails), upload the results.json as an artifact, and —key— decide when to trigger it (nightly, pre-release, on-demand, not on every PR). And we check the mechanism for real with a local mirror in shell: a step that runs the gate and uses its exit code to authorize or block, without touching git or gh.
Connection to the module: this lesson installs in a pipeline the gate you closed in lesson 6. It fully reuses module 7 (running k6 in CI, the load.yml, when to trigger it) and module 5 (the threshold that becomes the gate). Here the YAML goes as content —k6 isn't installed and git/gh are never run here—, but the mechanism (the exit code that fails the step) we run locally in Python so you see it's real. Lesson 8 will join this load.yml with everything else in the delivery. It's the piece that turns "a test you run" into "a test that runs itself".
The smoke detector wired to the alarm
A fire extinguisher on the wall is useful, but it depends on someone seeing the fire, grabbing it, and using it in time. A smoke detector wired to the alarm is another thing: it watches by itself, without anyone remembering, and when it detects smoke it acts —it triggers the alarm, cuts the ventilation, calls the firefighters— without waiting for a human to decide. The difference isn't the sensor (both "detect"); it's that one is wired to an automatic consequence and the other waits for a person.
Lesson 6's gate is the extinguisher: it works, but someone has to run it. The load.yml wires it to the alarm: the pipeline runs the test by itself (on a schedule, before a release), and if the threshold fails, it acts —it marks the build red, blocks the merge, stops the deploy— without anyone looking at a p95. The sensor is the same (the thresholds); what's new is the wiring to the consequence. And like every detector, you have to decide when you activate it: one that goes off with every piece of toast (every PR) is as useless as one turned off, because people learn to ignore it.
The load.yml (content)
Here's the capstone's workflow. Remember: labeled content, faithful to GitHub Actions syntax and to k6's official integration, not run here (k6 isn't installed and git/gh are never run here).
# .github/workflows/load.yml
# CONTENT (not run here): faithful to docs.github.com/actions and grafana.com/docs/k6.
name: Load test (Reservo)
# WHEN it triggers: NOT on every push/PR (it's slow and expensive). Yes on a schedule
# and on-demand.
on:
schedule:
- cron: '0 3 * * *' # every night at 03:00 UTC (nightly)
workflow_dispatch: {} # manual button (on-demand)
push:
tags:
- 'v*' # before a release (pre-release), when tagging vX.Y.Z
jobs:
load-test:
runs-on: ubuntu-latest
timeout-minutes: 20 # a load test must not hang the runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Bring up the Reservo API
# Starts the target in the background and waits for it to listen.
run: |
python3 reservo_server.py &
for i in $(seq 1 30); do
[ -f PORT ] && break
sleep 0.5
done
echo "BASE_URL=http://127.0.0.1:$(cat PORT)" >> "$GITHUB_ENV"
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Run the load test (the gate)
# k6 applies the script's thresholds. If ONE fails, k6 exits with 99,
# this step fails, and the whole job is marked red -> blocks the deploy.
run: k6 run quote_book_test.js --out json=results.json
env:
BASE_URL: ${{ env.BASE_URL }}
- name: Upload the result as an artifact
# `if: always()` -> the JSON is uploaded even if the test failed
# (exactly the one you want to review). The run's record.
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Read it by blocks, noticing how each capstone piece appears:
on:— when it runs. Three triggers, none is "on every push/PR".schedulewith acronruns it every night (nightly);workflow_dispatchgives a button to run it by hand (on-demand);pushonv*tags runs it before a release (pre-release). This is deliberate (we develop it below).- Bring up the API. The step starts
reservo_server.pyin the background, waits for it to write itsPORT, and saves theBASE_URLfor the k6 step. The target has to exist before hitting it (M1). - Install k6. The official
grafana/setup-k6-actionaction installs the binary on the runner (remember: k6 is a Go binary; the runner can install it, even though this guide's environment can't). - Run the test (the gate).
k6 run quote_book_test.js --out json=results.jsonruns the capstone's script (scenario + stages + thresholds) and exports the JSON. Here's the gate: if a threshold fails,k6 runexits with 99, the step fails, and the job goes red. M5's threshold became the pipeline's gate. - Upload the artifact with
if: always(). It uploadsresults.jsoneven if the test failed —theif: always()is key: the record of a red run is the one you most want to review—. The equivalent of lesson 6's "export before exiting."
When to trigger it (and why not on every PR)
The on: decision is as important as the rest of the YAML. A load test does not go on every pull request, for four reasons (M7):
- It's slow. A realistic profile lasts minutes (the capstone's, 10m30s). Putting it on every PR would make each change wait ten minutes for a test that almost always passes —people would start skipping it—.
- It's expensive. It runs sustained load on an environment; it consumes runners and, if the target is a shared environment, it saturates it for everyone. Multiplied by every PR of the day, it's an enormous cost.
- It needs a stable environment. A load test's numbers depend on the machine and its state. On a shared, noisy PR runner, the p95 varies so much that the gate would give false reds —and a gate that fails at random gets ignored—.
- Its signal is of another cadence. A functional bug you catch in seconds with a unit test, on every PR. A performance regression is slower to develop and more expensive to measure: it's enough to catch it every night or before each release, not on every commit.
That's why the capstone triggers it nightly (to catch accumulated regressions), pre-release (to not deploy a degradation), and on-demand (when someone wants to test a big change). Unit and E2E tests take care of every PR; the load test takes care of performance at a more measured cadence. Each test in its place in the pyramid.
The mechanism, run locally
The load.yml is content, but the mechanism that makes it work —the exit code that fails the step— is real, and we run it in Python. This ci_step.sh imitates the "run the test (the gate)" step: it runs the gate and uses its exit code to authorize or block, with the ::error:: annotation GitHub Actions uses to mark a step red. It doesn't use git or gh; it's just the shell showing the pipeline's logic:
#!/bin/bash
# LOCAL mirror of the CI step. Does NOT use git/gh; only the shell and the gate in Python.
BASE="$1"; TARGET="$2"
python3.14 loadtest.py "$BASE" "$TARGET" results.json > run.log 2>&1
CODE=$?
tail -n 3 run.log
if [ "$CODE" -ne 0 ]; then
echo "::error::The load test did not meet the SLO (exit $CODE). Deploy BLOCKED."
exit "$CODE"
fi
echo "Load test within the SLO. Deploy authorized."
First against the healthy build (/quote):
What to expect — the gate passes, the step doesn't mark an error, the job exits with 0 (green):
$ ./ci_step.sh "http://127.0.0.1:$(cat PORT)" /quote
== step: load test against /quote ==
metrics exported -> results.json
GATE: PASS (exit code 0)
Load test within the SLO. Deploy authorized.
$ echo $?
0
And against the heavy pricing engine (/quote_cpu):
What to expect — the gate fails, the step emits ::error:: and exits with 1: the job goes red and the deploy is blocked:
$ ./ci_step.sh "http://127.0.0.1:$(cat PORT)" /quote_cpu
== step: load test against /quote_cpu ==
metrics exported -> results.json
GATE: FAIL (exit code 1)
::error::The load test did not meet the SLO (exit 1). Deploy BLOCKED.
$ echo $?
1
That's the heart of the load.yml, run locally: the step runs the gate, and its exit code decides whether the job is green or red. In GitHub Actions, k6 run would act as the gate exiting with 99 instead of Python's 1, and ::error:: would mark the step —but the logic is identical: a code ≠ 0 puts the job red and stops the deploy. The YAML is the industrial form; the shell is the same mechanism, executed.
Common mistakes
Putting the load test on every PR. What happens: on: [push, pull_request] is added and every commit triggers a ten-minute test. Why it happens: the unit-test pattern gets copied. How to detect it: if your load.yml runs on every push, PRs become very slow and the team starts ignoring (or disabling) the test. How to fix it: trigger it with schedule (nightly), workflow_dispatch (on-demand), and push on tags (pre-release) —not on every PR—. The load test takes care of performance at a measured cadence; PRs are taken care of by fast tests.
Forgetting if: always() on the artifact. What happens: the step that uploads the results.json runs only if the previous ones succeeded, so failed runs leave no artifact. Why it happens: by default, a step doesn't run if a previous step failed. How to detect it: if the red build has no JSON attached, you forgot the if: always(). How to fix it: add if: always() to the upload-artifact step —the record of the red run is exactly the one you want to download to investigate—. It's the same principle as lesson 6's "export before exiting," in CI syntax.
Believing the load.yml ran here. What happens: someone sees the YAML and cites it as "what the guide did." Why it happens: the GitHub Actions content looks very real. How to detect it: k6 isn't installed and git/gh are never run here; the executed stuff is the shell mirror with python3.14 .... How to fix it: remember the rule —the load.yml is labeled content, faithful to the docs; what's run and demonstrates the mechanism is the gate in Python—.
Exercises
Exercise 1 — Choose the trigger. For each situation, say which on: trigger covers it: (a) catch regressions that accumulate over the week. (b) not deploy the v2.3.0 version if its performance degraded. (c) a dev wants to test their big branch's performance before merging.
See solution
- (a)
schedulewith acron(nightly): it runs every night and catches accumulated regressions without anyone remembering. - (b)
pushonv*tags (pre-release): when taggingv2.3.0, the test runs and blocks the release if the threshold fails. - (c)
workflow_dispatch(on-demand): the manual button the dev presses to run the test against their branch when they want.
The capstone's three triggers cover the three cadences; none is "on every PR."
Exercise 2 — Why 99 and not 1? The load.yml uses k6 run, which exits with 99 when a threshold fails; the Python gate exits with 1. (a) Does the difference matter to the pipeline? (b) Why does k6 reserve a specific code (99) instead of a generic 1?
See solution
- (a) No. The pipeline only distinguishes "0" (success) from "≠ 0" (failure). Both k6's 99 and Python's 1 are ≠ 0, so both fail the step and block the deploy exactly the same.
- (b) To distinguish the cause. k6 uses 99 (
ThresholdsHaveFailed) only when "the test ran completely but didn't meet the thresholds," and other codes for "script error", "setup failure", etc. That way, whoever reads the result knows whether the build went red from a broken threshold (a real performance problem) or from an error in the test itself (an infrastructure problem). The pipeline doesn't need the distinction, but the human investigating does (M5).
Exercise 3 — The gate that doesn't block. A team puts the load test in CI but adds continue-on-error: true to the k6 run step, "so the build doesn't go red." (a) What happens to the gate? (b) What do they have now, and what is it for?
See solution
- (a) The gate stops being a gate.
continue-on-error: truemakes the job continue (green) even if thek6 runstep fails with 99. The threshold is still evaluated, but its failure no longer stops anything: the deploy happens all the same, with the regression inside. - (b) They have the load test as monitoring, not as a gate: it measures and reports (the JSON, the summary), but it doesn't block. It's useful to observe the performance without risking blocking deploys —handy at the start, when you don't yet trust the test's stability, to gather data without holding up the team—. But while it has
continue-on-error, it doesn't protect the deploy: a regression passes all the same. Turning it into a real gate is removing thatcontinue-on-errorand letting the exit code do its job. (It's the CI equivalent of lesson 6's "decorative gate": it measures but doesn't block.)
Summary and next step
In this lesson you automated the test: you wired it to the alarm with the .github/workflows/load.yml (content). The workflow brings up the API, runs k6 with the thresholds as a gate (if a threshold fails, k6 run exits with 99 and the job goes red), and uploads the results.json as an artifact with if: always() to keep the record of the red runs. And you decided when to trigger it —nightly, pre-release, on-demand, not on every PR—, because a load test is slow, expensive, sensitive to the environment, and of a different cadence than a unit test. You checked the mechanism for real with a local shell mirror: a step that runs the gate and uses its exit code (with the ::error:: annotation) to authorize the deploy with the healthy build and block it with the heavy engine —without touching git or gh—.
You reused module 7 (running k6 in CI, when to trigger it) and module 5 (the threshold as a gate). Before moving on you should be able to: read a load.yml and explain each block; justify why it doesn't go on every PR; and explain why if: always() matters. What comes next, in lesson 8, is the delivery: joining the four pieces —the k6 script, the API, the Python run with its green and red gate, and this load.yml— into the complete load test, with a rubric. It closes the capstone and closes the guide.
Resources
- k6 — Running k6 in CI/CD — the official reference for how k6 is integrated in a pipeline and how the threshold acts as a gate; the source of the
load.yml. - GitHub Actions — Events that trigger workflows (
on:) — theschedule,workflow_dispatch, andpush: tagstriggers that decide when the test runs; the foundation of the "when to trigger it" section. - GitHub Actions — Storing workflow artifacts — how
upload-artifactwithif: always()saves each run'sresults.json(including the failed ones); the pipeline's record. - GitHub Actions — Workflow commands (
::error::) — the annotation that marks a step red, used in the local mirror; how the pipeline communicates a failure.