Skip to content

Gate regressions in GitHub Actions

Run baseline and candidate measurements on the same runner. This avoids mistaking differences between machines for differences between commits.

Commit the comparison policy

Keep the gate in pyproject.toml so local and CI comparisons use the same reviewed rules:

[tool.benchmatrix.compatibility]
mode = "permissive"

[tool.benchmatrix.evidence]
minimum_runs = 5
minimum_samples_per_run = 5
minimum_rounds_per_run = 5
require_raw_samples_for_inference = true
minimum_tail_samples_per_run = 100
require_tail_iterations_one = true

[tool.benchmatrix.inference]
method = "bca_bootstrap"
confidence_level = 0.95
resamples = 50000
random_seed = 0
multiplicity = "bonferroni"

[tool.benchmatrix.regression]
default_threshold_percent = 5.0

Validate it without running benchmarks:

uv run benchmatrix policy validate --quiet

Compare the pull request with its base commit

The following workflow checks out the pull request once, creates a detached worktree for its base commit, and measures both revisions sequentially on one runner. Replace tests/test_benchmarks.py with the project's benchmark target.

name: Benchmarks

on:
  pull_request:

permissions:
  contents: read

jobs:
  compare:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    env:
      BENCHMATRIX_VERSION: "1.1.0"

    steps:
      - name: Check out candidate
        uses: actions/checkout@v7
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@v7
        with:
          python-version-file: .python-version

      - name: Set up uv
        uses: astral-sh/setup-uv@v9.0.0

      - name: Collect baseline
        shell: bash
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          base_dir="$RUNNER_TEMP/benchmark-base"
          git worktree add --detach "$base_dir" "$BASE_SHA"
          (
            cd "$base_dir"
            uv sync --locked
            uv pip install --python .venv/bin/python \
              "benchmatrix==$BENCHMATRIX_VERSION"
            uv run --no-sync benchmatrix measure \
              --runs 5 \
              --output "$RUNNER_TEMP/benchmark-baseline" \
              tests/test_benchmarks.py
          )

      - name: Collect candidate
        shell: bash
        run: |
          uv sync --locked
          uv pip install --python .venv/bin/python \
            "benchmatrix==$BENCHMATRIX_VERSION"
          uv run --no-sync benchmatrix measure \
            --runs 5 \
            --output "$RUNNER_TEMP/benchmark-candidate" \
            tests/test_benchmarks.py

      - name: Compare runs
        shell: bash
        run: |
          uv run --no-sync benchmatrix compare \
            "$RUNNER_TEMP/benchmark-baseline" \
            "$RUNNER_TEMP/benchmark-candidate" \
            --format json \
            --github-summary \
            --fail-on-regression \
            > "$RUNNER_TEMP/benchmark-comparison.json"

      - name: Upload benchmark evidence
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: benchmark-comparison
          path: |
            ${{ runner.temp }}/benchmark-baseline
            ${{ runner.temp }}/benchmark-candidate
            ${{ runner.temp }}/benchmark-comparison.json
          if-no-files-found: warn
          retention-days: 14

--github-summary appends the Markdown report to the job summary. The JSON report and both collections remain available as workflow artifacts for later inspection.

Pin BENCHMATRIX_VERSION to the release reviewed for your project. Installing that same version after each revision's normal uv sync keeps the benchmark tool constant while allowing project dependencies to follow each lockfile. The benchmark target itself must exist on both revisions.

Choose the runner deliberately

Shared GitHub-hosted runners are convenient but can be noisy. For a blocking performance gate:

  • keep baseline and candidate in the same job and run them close together;
  • avoid unrelated CPU-heavy work in the benchmark job;
  • collect several runs and keep evidence thresholds enabled;
  • use a dedicated or otherwise stable runner when small changes matter;
  • treat an inconclusive result as a reason to rerun or investigate, not as proof of a regression or of equivalence.

The workflow above deliberately gathers independent groups, so its baseline and candidate phases remain separated in time. Keep those phases close together, minimize background work, and interpret slow thermal or runner drift as a possible limitation.

For a stronger local-blocking design, use collect-paired against two working trees in the same job. It alternates adjacent AB/BA blocks, gives both members the same balanced Williams-style matrix-cell order, crosses every order row with both orientations, records pair identity atomically, and feeds complete pairs to orientation-stratified paired BCa inference:

uv run benchmatrix collect-paired \
    --output paired-runs \
    --baseline-cwd ../baseline-worktree \
    --candidate-cwd . \
    -- \
    uv run pytest --benchmark-only tests/test_benchmarks.py \
    ::: \
    uv run pytest --benchmark-only tests/test_benchmarks.py

uv run benchmatrix compare paired-runs \
    --paired \
    --fail-on-regression \
    --format json > comparison.json

The independent workflow above remains useful when adjacent access to both variants is unavailable. The paired collector is also exposed as collect_paired_benchmark_runs in Python.

Optional paired precision planning estimates the fixed pair count for a fresh future collection from pilot log-ratio variability. It is not power analysis or a sequential rule, and its arithmetic difference from the pilot count must not be interpreted as “keep adding pairs until the gate passes.”

Do not execute code from untrusted forks on a persistent self-hosted runner unless each job is strongly isolated and ephemeral. Keep this workflow on the pull_request event—not pull_request_target—and retain minimal permissions.

The schema version 3 comparison report records environment compatibility, per-run observation counts, IQR, CV, outliers, the formal estimand and adjusted confidence interval, multiplicity family, deterministic seed, fallback method, and resolved threshold for every matrix cell. Retain it when debugging a failed gate.