CI/CD

Wiring It Into CI/CD

⏱ 17 min

Every test built so far in this course only protects you if it actually runs before a change merges, not whenever someone happens to remember to run it locally. This lesson wires the deterministic PHPUnit suite from Lessons 3 and 4 into GitHub Actions, against a real MySQL service container rather than a mocked database, and sets a branch protection rule so a red test blocks the merge, not just embarrasses it after the fact. It also wires in the eval suite from Lesson 5, deliberately on a different schedule, for the reasons that lesson already laid out.

What you'll learn in this lesson
A real GitHub Actions workflow for the PHPUnit suite
Composer install, a MySQL service container, and running phpunit against it.
Why a MySQL service container, not wp-env, for this pipeline
A lighter, faster setup that pairs with the Composer-based test library from Lesson 2.
Blocking a merge on a failing test
Branch protection rules requiring the workflow's status check to pass.
A separate, scheduled job for the eval suite
Real API cost and expected variance don't belong gating every pull request.
Prerequisites

The Composer-based PHPUnit setup from Lesson 2, and at least the test files from Lessons 3, 4, and 6 passing locally. A GitHub repository with Actions enabled and permission to add a branch protection rule.

Step 1: The PHPUnit workflow, against a real database service container

wp-phpunit/wp-phpunit, from Lesson 2, still needs a real MySQL database to create its test tables against, no dependency swap changes that. GitHub Actions’ service containers give you one for free, started and torn down automatically for each job run, no Docker-in-Docker layer like a full wp-env setup needs:

# File: .github/workflows/test.yml
name: Test
on:
  push:
    branches: [main]
  pull_request:
jobs:
  phpunit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.1', '8.3']
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: wordpress_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          tools: composer

      - name: Install dependencies
        run: composer install --no-progress --prefer-dist

      - name: Wait for MySQL to accept connections
        run: |
          until mysqladmin ping -h127.0.0.1 -P3306 -uroot -proot --silent; do
            sleep 1
          done

      - name: Run PHPUnit
        run: composer test

The options block on the mysql service isn’t decoration, it’s what makes the mysqladmin ping wait step reliable, the health check gives Actions its own signal that the container is actually ready to accept connections, not merely started.

Step 2: Why a service container instead of wp-env for this pipeline

Course 8’s CI/CD lesson runs its suite through wp-env inside GitHub Actions, a full containerized WordPress environment, which is a reasonable, real approach and works well when your local development already standardizes on wp-env. This pipeline takes a lighter route on purpose, pairing directly with the Composer-based wp-phpunit/wp-phpunit setup from Lesson 2: no Docker layer to start and wait on beyond the one MySQL container, and the exact same composer test command your local machine runs, in CI, with no translation between the two.

Neither approach is more correct, they solve the same problem differently

If your team already runs wp-env for local development, Course 8’s approach keeps local and CI environments identical, which is a real, legitimate reason to prefer it. This lesson’s approach optimizes for CI speed and for matching the Composer-native setup this course builds in Lesson 2. Pick the one that matches how your team already develops locally, running both is unnecessary duplication.

Step 3: Matrix across PHP and WordPress versions

What the matrix in Step 1 is actually protecting against
1
A PHP 8.1-only or 8.3-only bug
Code that works on the version you develop with locally but breaks on your plugin's stated minimum, or the newest release.
2
A WordPress version pin worth adding
Extend the matrix with a wordpress-develop version dimension if your plugin supports more than the latest release, resolved through the pinned wp-phpunit/wp-phpunit version from Lesson 2.

Keep the matrix as small as your plugin’s actual support window, every additional combination multiplies total CI time, and a matrix testing versions nobody runs in production is cost without signal.

Step 4: Block the merge, don’t just report the result

A workflow that runs but doesn’t gate anything is a suggestion, not a safeguard. In your repository’s branch protection settings (Settings, then Branches, then a protection rule on your default branch), require the phpunit job as a required status check before a pull request can merge. Once that’s set, a red test from Lesson 3’s permission suite or Lesson 4’s fake-summarizer suite makes the merge button unavailable, not just unattractive.

A required check with no matching job name silently blocks everything

GitHub Actions matches a required status check by the job’s name as it appears in the workflow run, phpunit in this workflow. Rename the job, or add a matrix without updating the required check to match a specific matrix leg, and pull requests can get stuck unable to merge even though the tests are actually passing, since the check GitHub is waiting for no longer appears under the old name. Confirm the exact check name in a real, recent workflow run before setting the branch protection rule.

Step 5: A separate, non-blocking job for the eval suite

Lesson 5’s eval suite makes real API calls and has expected run-to-run variance, so it does not belong in the workflow that gates every pull request. Give it its own workflow, on a schedule, informational rather than merge-blocking:

# File: .github/workflows/evals.yml
name: Evals
on:
  schedule:
    - cron: '0 6 * * *'
  workflow_dispatch:
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - name: Run eval suite
        run: wp eval-file tests/evals/tagging-eval.php
        continue-on-error: true

continue-on-error: true here is deliberate, the job still runs and its output is still visible in the Actions log every morning, but a below-threshold eval score doesn’t block a merge the way a PHPUnit failure does. workflow_dispatch lets anyone trigger it manually right before a prompt or provider change ships, exactly the moment Lesson 5 recommended actually reading its output closely.

Test it: push a deliberate regression and watch the pipeline catch it

Open a pull request that comments out the capability check in a permission_callback, the same deliberate break Lesson 3 used locally. Confirm the phpunit job goes red in the pull request’s checks, and, with the branch protection rule from Step 4 in place, confirm the merge button is genuinely disabled, not just showing a warning. That’s the whole pipeline working end to end, a real regression, caught before a human or an agent ever sees it in production.

Recap

A real CI/CD pipeline for ability testing runs the deterministic PHPUnit suite from Lessons 3 and 4 against a real MySQL service container in GitHub Actions, using the same composer test command a developer runs locally, then blocks merges on a failing required status check. The eval suite from Lesson 5 gets its own separate, scheduled workflow, informational rather than merge-blocking, since its cost and variance profile is entirely different from an exact-assertion test suite. Course 8’s wp-env based pipeline is a legitimate alternative, pick whichever matches how your team already develops locally.

Resources & further reading

← Contract-Testing MCP Tools Against Real Clients Course Recap: A Testing Checklist Before Shipping an Ability Change →