GitHub Actions CI/CD Pipeline for WordPress Plugin Testing and Deployment

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.