GitHub Actions provides a free CI/CD pipeline for public repositories and 2,000 minutes per month for private repositories — enough to run PHPUnit tests, PHP_CodeSniffer linting, PHPStan static analysis, and a deployment step on every push and pull request for a WordPress plugin. A workflow is defined as a YAML file in .github/workflows/ — a single file can contain multiple jobs that run in parallel (each in its own ephemeral Ubuntu, macOS, or Windows virtual machine), and each job contains sequential steps that run shell commands or call reusable actions from the GitHub Marketplace. The shivammathur/setup-php action installs a specific PHP version with extensions and coverage drivers in a single step — it supports PHP 7.4 through 8.3, xdebug, pcov, and all standard extensions. WordPress plugin CI typically requires: (1) a PHP/Composer step to install dependencies; (2) a WordPress test environment step using the mysql service container and the wp scaffold plugin-tests bootstrap; (3) PHPUnit for unit and integration tests; (4) PHP_CodeSniffer with the WordPress Coding Standards ruleset for code style; (5) PHPStan with the SzepeViktor/phpstan-wordpress stubs for static analysis. A matrix strategy runs the same job across multiple PHP versions and WordPress versions simultaneously — a matrix of PHP 8.0/8.1/8.2 against WordPress 6.3/6.4/latest produces nine parallel test runs that complete faster than a single sequential run would. The deployment job runs only on pushes to the main branch after the test jobs pass — using needs: [test] dependency and an if: github.ref == ‘refs/heads/main’ condition. Deployment targets for WordPress plugins include: the WordPress.org SVN repository (via the 10up/action-wordpress-plugin-deploy action), a staging server via rsync over SSH, or a private Composer package repository via composer archive + scp. The git bisect post covered finding regressions after they reach production; GitHub Actions CI catches regressions before they merge by running the test suite on every pull request.
Problem: A WordPress plugin team of four developers merges code to main manually after visual code review. Three separate incidents in one month introduced PHP fatal errors that only appeared on PHP 8.1 (the team tests locally on 8.0), a WordPress Coding Standards violation that caused a rejected plugin submission, and a regression caught by a PHPUnit test that had not been run in two weeks.
Solution: Add a GitHub Actions workflow that runs PHPUnit on PHP 8.0/8.1/8.2, PHP_CodeSniffer with WPCS, and PHPStan on every pull request — blocking merges that fail any check — and a second workflow job that deploys to WordPress.org SVN when a release tag is pushed.
# .github/workflows/ci.yml
name: WordPress Plugin CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
# ── PHPUnit + WPCS + PHPStan ─────────────────────────────────────────────
test:
name: PHP ${{ matrix.php }} / WP ${{ matrix.wordpress }}
runs-on: ubuntu-latest
strategy:
fail-fast: false # let all matrix jobs finish even if one fails
matrix:
php: [ "8.0", "8.1", "8.2" ]
wordpress: [ "6.3", "6.4", "latest" ]
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-retries=5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, mysql, xml, zip
coverage: none # set to 'xdebug' to generate coverage reports
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Install WordPress test suite
run: |
bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 ${{ matrix.wordpress }}
- name: Run PHPUnit
run: vendor/bin/phpunit --no-coverage
- name: Run PHP_CodeSniffer (WPCS)
run: vendor/bin/phpcs --standard=WordPress src/
- name: Run PHPStan
run: vendor/bin/phpstan analyse src/ --level=5
# ── Deploy to WordPress.org on release tag ───────────────────────────────
deploy:
name: Deploy to WordPress.org
runs-on: ubuntu-latest
needs: test # only runs if all test matrix jobs pass
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: WordPress Plugin Deploy
uses: 10up/action-wordpress-plugin-deploy@stable
env:
SVN_PASSWORD: ${{ secrets.WPORG_SVN_PASSWORD }}
SVN_USERNAME: ${{ secrets.WPORG_SVN_USERNAME }}
SLUG: my-plugin-slug # WordPress.org plugin slug
# bin/install-wp-tests.sh — required by wp scaffold plugin-tests
# Installs WordPress core + test suite into /tmp/wordpress-tests-lib
# Parameters: DB_NAME DB_USER DB_PASS DB_HOST WP_VERSION
#!/usr/bin/env bash
set -e
DB_NAME=$1 DB_USER=$2 DB_PASS=$3 DB_HOST=$4 WP_VERSION=${5:-latest}
WP_TESTS_DIR=${WP_TESTS_DIR:-/tmp/wordpress-tests-lib}
WP_CORE_DIR=${WP_CORE_DIR:-/tmp/wordpress}
download() {
if [ $(which curl) ]; then
curl -s "$1" > "$2"
elif [ $(which wget) ]; then
wget -nv -O "$2" "$1"
fi
}
if [ $WP_VERSION == 'latest' ]; then
local_ver=$(download https://api.wordpress.org/core/version-check/1.7/ - | python3 -c "import sys,json; print(json.load(sys.stdin)['offers'][0]['version'])")
WP_VERSION=$local_ver
fi
WP_TESTS_TAG="tags/$WP_VERSION"
[ "$WP_VERSION" == 'trunk' ] && WP_TESTS_TAG="trunk"
# Download WP core and test suite via SVN
if [ ! -d $WP_CORE_DIR ]; then
svn co --quiet "https://develop.svn.wordpress.org/${WP_TESTS_TAG}/src/" $WP_CORE_DIR
fi
if [ ! -d $WP_TESTS_DIR ]; then
svn co --quiet "https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/" $WP_TESTS_DIR/includes
svn co --quiet "https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/" $WP_TESTS_DIR/data
fi
# Create test database
mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS" --host="$DB_HOST" --protocol=tcp 2>/dev/null || true
# Write wp-tests-config.php
cat > $WP_TESTS_DIR/wp-tests-config.php <<PHP
<?php
define( 'DB_NAME', '$DB_NAME' );
define( 'DB_USER', '$DB_USER' );
define( 'DB_PASSWORD', '$DB_PASS' );
define( 'DB_HOST', '$DB_HOST' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'ABSPATH', '$WP_CORE_DIR/' );
PHP
NOTE: GitHub Actions secrets (secrets.WPORG_SVN_PASSWORD) are encrypted at rest and redacted from workflow logs automatically — never hard-code credentials in workflow YAML files or in the repository. Branch protection rules (Settings → Branches → Branch protection rules) enforce that the CI workflow passes before a pull request can be merged — enable “Require status checks to pass before merging” and select the test job name from the CI workflow. The matrix strategy produces one status check per PHP/WP combination — add all of them to the required checks list to prevent merging if any PHP version fails. For plugins that use a build step (npm, Webpack, Composer install with --no-dev), add a build job that produces a .zip artifact with actions/upload-artifact — the deploy job then downloads it with actions/download-artifact and deploys the production-ready zip rather than the raw source.