PerfLoad / GitHub Actions load testing
GitHub Actions Load Testing With a Saved Baseline Run
A GitHub Actions workflow can run a PerfLoad load test the same way it runs any other check: pull a saved test configuration, start a run, wait for it to finish, and read the numbers. Below is a working workflow that ends with p95 latency available as a step output for whatever should happen next.
Add Load Testing to GitHub Actions →Needs a PerfLoad runner the workflow can reach — see runner networking below.
What the workflow does
- Retrieves a baseline configuration from the runner:
GET /runs/<baseline id>, taking the.configobject. - Starts a new load test with
POST /runs, overriding the virtual users. - Waits for completion by polling
GET /runs/<id>/statusuntil it'sfinished,failed, orstopped. - Retrieves the summary from
GET /runs/<id>/summary. - Exposes p95 latency by writing it to
$GITHUB_OUTPUT, so later steps can read it assteps.load_test.outputs.p95.
The workflow
name: load-test
on:
workflow_dispatch:
inputs:
users:
description: Virtual users
default: "50"
jobs:
load-test:
runs-on: ubuntu-latest
env:
RUNNER: ${{ vars.RUNNER_URL }}
BASELINE_RUN_ID: ${{ vars.BASELINE_RUN_ID }}
USERS: ${{ inputs.users || '50' }}
steps:
- name: Run load test
id: load_test
run: |
# Reuse the baseline config, overriding virtual users
CONFIG=$(curl -sf "$RUNNER/runs/$BASELINE_RUN_ID" \
| jq --argjson users "$USERS" '.config | .users = $users')
# Start a new run
NEW_ID=$(curl -sf -X POST "$RUNNER/runs" \
-H "Content-Type: application/json" \
-d "$CONFIG" | jq -r '.id')
echo "run_id=$NEW_ID" >> "$GITHUB_OUTPUT"
# Wait for a terminal status
while true; do
STATUS=$(curl -sf "$RUNNER/runs/$NEW_ID/status" | jq -r '.status')
[ "$STATUS" = "finished" ] || [ "$STATUS" = "failed" ] || [ "$STATUS" = "stopped" ] && break
sleep 2
done
[ "$STATUS" = "finished" ] || { echo "Run ended as $STATUS"; exit 1; }
# Read the summary and expose p95 to later steps
SUMMARY=$(curl -sf "$RUNNER/runs/$NEW_ID/summary")
echo "p95=$(jq '.metrics.http_req_duration.values["p(95)"]' <<< "$SUMMARY")" >> "$GITHUB_OUTPUT"
jq '.metrics' <<< "$SUMMARY"
- name: Show p95 latency
env:
P95: ${{ steps.load_test.outputs.p95 }}
run: echo "p95 latency = ${P95} ms"
- name: Fail if p95 is over budget
env:
P95: ${{ steps.load_test.outputs.p95 }}
BUDGET_MS: "500"
run: awk -v p="$P95" -v b="$BUDGET_MS" 'BEGIN { exit !(p != "" && p + 0 <= b + 0) }'
The load-test step follows the runner's documented CI/CD example, with three changes: variables come in through
env rather than being interpolated into the script, the virtual-user count is a workflow input, and
the step fails if the run doesn't end as finished. The final step is not a PerfLoad feature; it's a
plain shell comparison against a 500 ms budget you'd tune for your own service. Latency in the summary is in
milliseconds.
Setting it up
- Run the test you want in the PerfLoad Workbench or Dashboard and copy its Run ID.
- In your repository, add two variables under Settings → Secrets and variables → Actions:
RUNNER_URL(for examplehttp://my-runner:3000) andBASELINE_RUN_ID. - Commit the workflow, then trigger it from the Actions tab.
Variables are fine for these two values, since neither is a credential. If your runner URL should stay private,
store it as a secret and reference it with secrets.RUNNER_URL instead.
Where the runner lives matters
Jobs on GitHub-hosted runners start on the public internet, so RUNNER_URL must be reachable from
there. If your PerfLoad runner sits in a private network next to the service you're testing, run the job on a
self-hosted
GitHub Actions runner inside that network instead. The PerfLoad runner API doesn't document built-in
authentication, so avoid exposing it to the open internet; see
self-hosted load testing for the deployment side.
Smoke on every change, load on demand
The users input is the simplest override. Extend the same jq expression to change
duration as well — .config | .users = 5 | .duration = "30s" for a quick check on pull
requests, and a longer profile for a manually dispatched pre-release run. Both replay the same saved baseline,
so the request, headers, and variables stay identical between runs.
Using the p95 output
Anything that can read a step output can use it: a comment on the pull request, a line in the job summary, or
the budget check in the workflow above. The step's other output, run_id, is there so a follow-up
step can link to the run or, for example, request a comparison report from
POST /runs/compare/report.pdf.
FAQ
Can I trigger this on every pull request?
You can change on: to pull_request. Since each run starts a k6 process on the runner and runs aren't queued, keep PR-triggered runs small (a few users, short duration), and reserve larger runs for manual or release triggers.
Will the baseline Run ID keep working after the runner is updated?
As long as run history is on persistent storage, such as the perfload-runs Docker volume or a Kubernetes PersistentVolumeClaim, yes. Updating the image doesn't touch it.
Is there a PerfLoad GitHub Action I can install?
The documented integration is the runner's REST API, called with curl and jq from a normal run: step, which is what the workflow above does. There is nothing to install in the workflow.
Related guides
Get p95 into your workflow
Save a baseline run, add two variables, and let Actions replay it.
Add Load Testing to GitHub Actions →