Why gate PRs on performance
A merge that makes one endpoint 40% slower is a performance regression. Without a CI check, it ships to production — usually discovered during an incident, never during code review.
Adding a load test to your PR workflow means:
- Engineers see latency numbers alongside test coverage
- Regressions are caught at the author's desk, not the on-call engineer's 3 AM
- Performance becomes a first-class part of your definition of done
The workflow file
Create .github/workflows/load-test.yml:
name: Load Test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup k6
uses: grafana/setup-k6-action@v1
- name: Run smoke test
run: k6 run tests/smoke.js
env:
BASE_URL: ${{ vars.STAGING_URL }}
K6_CLOUD_TOKEN: ${{ secrets.PERFSCALE_TOKEN }}
- name: Post results comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const summary = fs.readFileSync('k6-summary.json', 'utf8');
const data = JSON.parse(summary);
const p99 = data.metrics.http_req_duration.values['p(99)'];
const errorRate = data.metrics.http_req_failed.values.rate;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `### Load test results\n\n| Metric | Value |\n|--------|-------|\n| p99 latency | ${p99.toFixed(0)}ms |\n| Error rate | ${(errorRate * 100).toFixed(2)}% |`
});
Writing a CI-friendly smoke test
// tests/smoke.js
import http from 'k6/http';
import { check } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
vus: 10,
duration: '2m',
thresholds: {
http_req_duration: ['p(99)<500'],
http_req_failed: ['rate<0.01'],
},
// Write summary JSON for the comment step
summaryTrendStats: ['avg', 'p(95)', 'p(99)'],
};
export function handleSummary(data) {
return {
'k6-summary.json': JSON.stringify(data),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}
export default function () {
const res = http.get(`${BASE_URL}/api/health`);
check(res, { 'status is 200': (r) => r.status === 200 });
}
Secrets and environment variables
Set these in your GitHub repo settings:
| Secret / Variable | Value |
|---|---|
PERFSCALE_TOKEN | API token from your perfscale dashboard |
STAGING_URL | Your staging environment base URL (use a variable, not a secret) |
Never hardcode URLs or tokens in scripts committed to a public repo.
Separating smoke from stress tests
Don't run a 30-minute stress test on every PR — that blocks the pipeline. Instead:
on:
pull_request:
# smoke test only
schedule:
- cron: '0 2 * * *' # stress test nightly at 2 AM
Use the ${{ github.event_name }} context to decide which test to run:
- name: Decide test type
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
k6 run tests/smoke.js
else
k6 run tests/stress.js
fi
Failing the build on threshold breach
k6 exits with code 1 when a threshold is breached. GitHub Actions treats any non-zero exit code as a failure. So your thresholds automatically become CI gates — no extra configuration needed.
If you want a softer gate (report but don't block), add abortOnFail: false to the threshold:
thresholds: {
http_req_duration: [
{ threshold: 'p(99)<500', abortOnFail: false }
],
},
The test will still report the breach in the summary and post the comment, but the workflow step won't fail.