CI/CD7 min readFebruary 10, 2025

Continuous Load Testing with GitHub Actions

Add performance gates to every pull request using k6 and perfscale. Configuration, secrets, and PR comment reporting included.

By Perfscale Team

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 / VariableValue
PERFSCALE_TOKENAPI token from your perfscale dashboard
STAGING_URLYour 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.