Integrating k6 into automation pipelines with GitHub Actions, GitLab CI, and Jenkins, understanding k6 exit codes as the universal language of test results, leveraging report artifacts, and structuring a smoke test strategy on every merge request and a full load test on release.

In episode 15, you learned to handle degradation measurably: distinguishing flakes from fails, writing retries with backoff, and dissecting root causes from the metric breakdown. All those skills, however sophisticated, are only valuable if they are run automatically. A test run manually on a laptop protects nothing — its results stay in the terminal instead of becoming a decision.
Episode 16 changes k6's position from a diagnostic tool into a pipeline watchdog: your scripts are run automatically by CI/CD on every merge request and every release, and performance failures stop the process before they hurt users. We'll cover GitHub Actions, GitLab CI, and Jenkins, plus the universal language that connects k6 to all of them: the exit code.
Think of k6 as a security guard at the gate. If the guard only stands watch when someone reminds him, many uninvited guests get in. The pipeline is consistent guarding: every code change passes through the same gate, with the same standards, without relying on human memory.
Three strong reasons to put k6 in CI/CD:
k6 ends its process with an exit code that any CI can read. Because all pipelines determine success or failure from a step's exit code, a violated threshold fails the build without a single line of extra script:
| Exit code | Meaning | CI action |
|---|---|---|
0 | Test finished, all thresholds met | Continue to the next stage |
1 | General error: file not found, execution failed | Fix the setup and script |
99 | One or more thresholds violated | Fail the build and upload the report |
107 | Exception inside the script | Fix the bug in the script |
Notice 107 and 99: both fail the build, but they mean different things. 107 indicates your script is broken — fix it before continuing. 99 indicates the system under test didn't meet performance targets — this is actually valuable information, because it's a decision result, not a bug.
GitHub Actions is the most common choice for repositories that already live on GitHub. Its ecosystem provides the official grafana/k6-action@v0.3.1 action that wraps k6: you just point it at a script file and the action runs k6 run script.js, then forwards the exit code (including 99) so a failed threshold automatically fails the job. An example workflow that runs a smoke test on every pull request and uploads a report:
name: Performance Smoke Test
on:
pull_request:
paths:
- 'api/**'
- 'tests/load/**'
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run k6 smoke test
uses: grafana/k6-action@v0.3.1
with:
filename: ./tests/load/smoke.js
flags: --out json=reports/smoke.json
- name: Upload k6 report
uses: actions/upload-artifact@v4
if: always()
with:
name: k6-report
path: reports/smoke.jsonThere are two important details. First, with.filename points to the script relative to the repo root; flags can contain anything you'd normally pass to k6 run — including --out to store JSON results. Second, if: always() on the upload step ensures the report is still uploaded even when the build fails — precisely when it's most needed for debugging. Without it, the report is lost on a discarded runner.
If your team doesn't want to add an action dependency, k6 can be run directly through the official image:
docker run --rm -i grafana/k6 run - < script.jsThe run - pattern reads the script from stdin so no file mounting is needed — concise for a single script, although mounting a folder is more practical when the script depends on other files.
In GitLab CI, the concept is the same, only the shape is jobs inside .gitlab-ci.yml. The loadimpact/k6 image (the old name still published for compatibility; the grafana/k6 image also works) already contains the k6 binary, so the script just calls the k6 run script.js command:
stages:
- test
load-test:
stage: test
image: loadimpact/k6
script:
- k6 run tests/load/smoke.js --out json=results.json
artifacts:
when: always
paths:
- results.json
expire_in: 30 daysWhen a threshold is violated, k6 run exits with code 99 and GitLab marks the job as failed — no extra logic needed. The artifacts block with when: always saves the JSON report even when the job fails, and expire_in keeps pipeline storage clean.
In Jenkins, the same pattern is expressed in a Jenkinsfile. The following declarative pipeline runs k6 inside a Docker container and archives the report:
pipeline {
agent {
docker {
image 'grafana/k6'
}
}
stages {
stage('Load Test') {
steps {
sh 'k6 run tests/load/smoke.js --out json=results.json'
}
}
}
post {
always {
archiveArtifacts artifacts: 'results.json'
}
}
}When a threshold fails, the sh step receives exit code 99 and the pipeline stops with a red status; the post block ensures the report is archived whether the run succeeds or fails.
Running all types of tests at every stage is a waste of CI minutes and makes the team reluctant to touch the pipeline. Distribute the load according to frequency and cost:
main or staging → a small load test with moderate load to ensure cross-feature integration doesn't break performance.A smoke test on a PR works like a clutch check before a race: cheap, fast, and filters out the most obvious problems. A full load test at release is the thorough inspection just before the start.
| Mistake | Impact | Fix |
|---|---|---|
Not uploading artifacts with when: always | Reports are lost when the build fails | Upload the report in an always block |
| Full load test on every PR | Slow, expensive CI; the team avoids tests | Trim it down to a smoke test on PRs |
Confusing 99 and 107 | Misdiagnosis: system vs script | Read the exit code before blaming |
| Thresholds so tight they flake | The team disables the test unilaterally | Handle flakes with retry (episode 15) |
| Runner with unstable resources | Non-reproducible results | Pin the runner type or CI machine |
Episode 16 closes the loop you started drawing in episode 15: test results no longer stop at the terminal, but become an automated gate in GitHub Actions, GitLab CI, and Jenkins. You understand the exit codes 0, 1, 99, and 107 as a universal language, use artifacts to store reports, and structure a tiered strategy from smoke tests on PRs to full load tests at release.
However, a single machine in the pipeline can only generate limited load. What if your target is hundreds of thousands of simultaneous users? In episode 17 we step beyond the limits of a single machine: distributed load testing and cloud execution with k6 Cloud, load zones, and the self-managed alternative using Kubernetes. See you there!