Плагин smallPict для WordPress конвертирует загружаемые изображения в WebP и AVIF и отдает их посетителям. Когда мы ставим тег релиза, GitHub Actions коммитит его в SVN-репозиторий WordPress.org, а оттуда он попадает на каждый сайт, где установлен плагин. Сайты с включенными автообновлениями устанавливают его, и никто не просматривает изменения. Плагин объявляет Requires PHP: 7.4 и Requires at least: 5.9, поэтому одна сборка должна работать и на виртуальном хостинге, который всё ещё на PHP 7.4, и в контейнере с PHP 8.5.
Фатальная ошибка в плагине может положить wp-admin. Неэкранированное значение на странице настроек означает XSS сразу на всех сайтах. Пропущенная проверка nonce превращает действие администратора в цель для CSRF. Ничего из этого нельзя исправить незаметно: для исправления нужен новый релиз, и часть сайтов остается на сломанной версии, пока он не выйдет.
В этой статье описан наш CI/CD-пайплайн для плагина WordPress: WordPress Coding Standards (WPCS), PHPStan и автотесты, которые запускаются на семи версиях PHP, и проверка перед деплоем на WordPress.org. Всё, что описано ниже, взято из репозитория smallpict-wp на релизе 1.2.9.
Главное
- Каждый pull request в
masterпараллельно запускает в GitHub Actions WPCS, PHPStan и 9 скриптов автотестов на PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 и 8.5.- WPCS требует санитизации ввода, экранирования вывода, nonce и подготовленных запросов. PHPStan на уровне 5 находит ошибки типов и мертвые ветки по стабам WordPress, не запуская WordPress.
- Тег релиза снова запускает WPCS на всех семи версиях PHP, и задание, которое коммитит в SVN WordPress.org, стартует только после того, как пройдут все семь.
CI/CD-пайплайн плагина WordPress в общих чертах
Плагин защищают два workflow в GitHub Actions.
| Workflow | Когда запускается | Задания | Проверки |
|---|---|---|---|
phpcs.yml (PHP Code Quality & Standards) | push в master или develop, pull request в master | по одному заданию на версию PHP: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 | WPCS, PHPStan, автотесты |
deploy-all.yml (Deploy to WordPress.org) | push тега v* | WPCS на каждой из 7 версий PHP, затем deploy | WPCS, затем сборка и коммит в SVN |
Примечание о покрытии версий PHP: Скриншоты в этой статье показывают наши более ранние прогоны CI на PHP от 7.4 до 8.3. Сейчас тестовая матрица в GitHub Actions расширена до PHP от 7.4 до PHP 8.5, что обеспечивает постоянную совместимость с современными средами выполнения PHP без регрессий в старых окружениях.
Все инструменты подключены как dev-зависимости Composer, поэтому ничего из них не попадает к пользователям:
"require-dev": { "wp-coding-standards/wpcs": "^3.4.1", "phpcsstandards/php_codesniffer": "^3.0", "phpstan/phpstan": "^2.1", "szepeviktor/phpstan-wordpress": "^2.0 || ^1.3.5", "phpstan/extension-installer": "^1.4"}Тестирование на матрице версий PHP в GitHub Actions
Что меняется между PHP 7.4 и PHP 8.5
PHP 7.4 и PHP 8.5 выполняют один и тот же исходный код плагина по-разному. Вот некоторые изменения, которые затрагивают код плагинов WordPress:
- В PHP 8.0 многие встроенные функции стали выбрасывать
TypeErrorпри аргументе неверного типа, тогда как 7.4 возвращалnullс предупреждением. Код, который передавал массив вstrlen(), продолжал работать на 7.4 и останавливается на 8.0. - В PHP 8.0 изменилось сравнение числа с нечисловой строкой.
0 == "abc"даетtrueна 7.4 иfalseна 8.0, из-за чего часть нестрогих сравнений со значениями опций молча меняет результат на противоположный. - В PHP 8.0 появились
str_starts_with()и родственные функции. Наш обработчик изображений вызываетstr_starts_with(). На PHP 7.4 это работает только потому, что WordPress 5.9 и новее содержат полифил, а его наличие гарантирует нашRequires at least: 5.9. - В PHP 8.1 передача
nullв параметры встроенных функций, не допускающие null, объявлена устаревшей.strtolower( wp_parse_url( $url, PHP_URL_HOST ) )работает без замечаний на 7.4 и пишет в лог deprecation на 8.1, когда$urlотносительный и хост равенnull. Некоторые хостинги выводят deprecation-сообщения прямо на страницу. - В PHP 8.2 динамические свойства объявлены устаревшими. Присваивание необъявленному свойству одного из ваших собственных классов пишет deprecation в лог на каждом запросе.
Каждое из этих изменений проявляется только тогда, когда код анализируется или выполняется на той версии PHP, которая ведет себя иначе. Поэтому пайплайн прогоняет каждый pull request в master сразу на всех семи версиях.
Матрица GitHub Actions

Рисунок 1: Workflow PHP Code Quality & Standards для слияния работы над 1.2.0 в master. Каждая версия PHP выполняется отдельным заданием, а задание PHP 8.3 показывает шаги, которые выполняет каждое задание: PHPCS, PHPStan, затем автотесты.
Примечание о покрытии версий PHP: Скриншоты выше показывают наши более ранние прогоны CI на PHP от 7.4 до 8.3. Сейчас тестовая матрица в GitHub Actions расширена до PHP от 7.4 до PHP 8.5, что обеспечивает постоянную совместимость с современными средами выполнения PHP без регрессий в старых окружениях.
jobs: code-quality: name: WPCS & PHPStan (PHP ${{ matrix.php-version }}) runs-on: ubuntu-26.04 strategy: matrix: php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer:v2, cs2pr ini-values: memory_limit=2G coverage: none - run: composer install --prefer-dist --no-progress --no-interaction - name: Run PHPCS (WordPress Coding Standards) run: vendor/bin/phpcs -q --standard=phpcs.xml.dist --report=checkstyle | cs2pr - name: Run PHPStan (WordPress Static Analysis) run: vendor/bin/phpstan analyse --memory-limit=2G - name: Run the standalone tests run: | for t in tests/test_*.php; do echo "== $t" php "$t" || exit 1 donefail-fast сохраняет значение по умолчанию true, поэтому, когда одна версия падает, GitHub отменяет еще выполняющиеся задания. Сломанный pull request обходится дешево, но в одном запуске вы видите результаты не всех версий.
Что матрица доказывает, а что нет
Три проверки получают от матрицы разную пользу. PHPStan анализирует код для той версии PHP, на которой запущен, а тесты выполняют код на этой версии, поэтому оба могут дать разный ответ в каждом задании. PHPCS работает с токенами и на практике дает одинаковый ответ на всех семи версиях; в матрице он в основном доказывает, что инструменты устанавливаются и запускаются на каждой версии. Проверять синтаксис и функции по диапазону версий PHP за один проход должен PHPCompatibility, которого в нашем наборе правил пока нет.
Автоматизация WordPress Coding Standards (WPCS)
Сниффы безопасности, важные для плагина
WPCS представляет собой набор сниффов PHP_CodeSniffer, написанных для WordPress. Правила, которые важнее всего для безопасности плагина, находятся в самом наборе правил WordPress:
WordPress.Security.ValidatedSanitizedInputотмечает любое чтение$_POST,$_GET,$_REQUESTили$_COOKIE, которое не прошло unslash и санитизацию.WordPress.Security.EscapeOutputотмечает любойechoилиprintзначения, которое не прошло через функцию экранирования, напримерesc_html(),esc_attr()илиesc_url().WordPress.Security.NonceVerificationотмечает обработчик, который читает данные запроса без проверки nonce.WordPress.DB.PreparedSQLиPreparedSQLPlaceholdersотмечают запрос, собранный конкатенацией строк вместо$wpdb->prepare(), и плейсхолдеры, которые не совпадают со своими аргументами.
Наш набор правил WPCS
phpcs.xml.dist добавляет WordPress-Extra и WordPress-Docs, фиксирует text domain для сниффов переводов и требует, чтобы каждая глобальная функция, класс и константа имели наш префикс. Так плагин не может конфликтовать с именами другого плагина на том же сайте:
<rule ref="WordPress"> <exclude name="Universal.Arrays.DisallowShortArraySyntax"/></rule><rule ref="WordPress-Extra"/><rule ref="WordPress-Docs"/>
<rule ref="WordPress.WP.I18n"> <properties> <property name="text_domain" type="array"> <element value="smallpict"/> </property> </properties></rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals"> <properties> <property name="prefixes" type="array"> <element value="smallpict"/> <element value="SmallPict"/> <element value="SMALLPICT"/> </property> </properties></rule>AJAX-обработчик, который проходит WPCS
Этот обработчик из плагина пакетами восстанавливает выгруженные (offloaded) медиафайлы. Все входные границы обрабатываются в его первых строках:
public function ajax_restore_offloaded() { if ( ! check_ajax_referer( 'smallpict_restore_offloaded', 'nonce', false ) || ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => 'forbidden' ), 403 ); } global $wpdb; $after = isset( $_POST['after'] ) ? absint( wp_unslash( $_POST['after'] ) ) : 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin action, the rows change on every call. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d", '_smallpict_offloaded', '1', $after, self::RESTORE_BATCH ) ); // ...}Nonce и права (capability) проверяются вместе, потому что валидный nonce доказывает, что запрос пришел с нашей страницы администратора, но не то, что пользователю разрешено выполнить действие. $_POST['after'] проходит unslash и приводится через absint() до того, как попадет в запрос, а запрос всё равно идет через $wpdb->prepare(), хотя значение уже целое число.
Подавление требует имени сниффа и причины
Строка phpcs:ignore выше оставлена намеренно. WPCS предупреждает о прямых запросах к базе данных без объектного кеширования. Для чтения на фронтенде это хороший совет, а для действия администратора, строки которого меняются при каждом вызове, неверный. Наше правило: каждый ignore называет конкретный снифф и указывает причину после --. Голый // phpcs:ignore не проходит ревью.
В CI PHPCS пишет отчет в формате checkstyle, а cs2pr превращает его в аннотации на диффе pull request, так что ревьюер видит каждое нарушение на строке, которая его вызвала, а не в логе.
Поиск логических ошибок статическим анализом PHPStan
WPCS читает код построчно. Он знает, что у echo нет вызова экранирования, но не знает, какой тип хранит переменная и может ли ветка когда-нибудь выполниться. PHPStan строит модель типов, которые проходят через каждую функцию, и сообщает о коде, который ей противоречит: вызов несуществующего метода, передача string|false туда, где требуется string, сравнение значений, которые никогда не могут быть равны, условия, которые всегда истинны или всегда ложны.
PHPStan для WordPress без запуска WordPress
szepeviktor/phpstan-wordpress передает PHPStan сигнатуры функций и классов ядра WordPress через файлы-стабы, а также несколько расширений типов, специфичных для WordPress. phpstan/extension-installer регистрирует его автоматически. PHPStan читает эти объявления и никогда не загружает WordPress или базу данных.
Наша конфигурация PHPStan
parameters: level: 5 paths: - smallpict.php - includes/ - admin/ treatPhpDocTypesAsCertain: false bootstrapFiles: - phpstan-bootstrap.phpУровень 5 проверяет тип каждого аргумента, переданного в функцию или метод, вдобавок к проверкам нижних уровней: неизвестные классы, методы и свойства, возвращаемые типы и мертвый код. treatPhpDocTypesAsCertain: false говорит PHPStan не считать наши собственные докблоки @param доказательством. WordPress передает то, что вернул фильтр, поэтому параметр, задокументированный как int, может прийти строкой из другого плагина, и мы хотим, чтобы PHPStan сохранял защитные проверки на этот случай. Bootstrap-файл определяет константы плагина (SMALLPICT_PATH, SMALLPICT_URL и другие), которые обычно задаются во время выполнения.
![Лог статического анализа PHPStan для WordPress в GitHub Actions: шаг WPCS без замечаний, PHPStan проанализировал 11 из 11 файлов плагина с результатом «[OK] No errors»](/blog-assets/02_phpcs_phpstan_log.png)
Рисунок 2: Шаги PHPCS и PHPStan в задании PHP 8.3. PHPCS ничего не выводит, когда сообщать не о чем. PHPStan анализирует 11 файлов плагина (основной файл плагина и 10 классов в includes/) и не находит ошибок.
Реальная находка PHPStan: строка 626 обработчика изображений
2 октября 2026 года пайплайн упал на PHPStan с одной ошибкой:

Рисунок 3: Задание PHP 8.2 остановилось на шаге PHPStan с booleanAnd.rightAlwaysTrue в строке 626 файла includes/class-smallpict-image-processor.php. На тот момент в анализируемых путях у плагина было 9 файлов.
Этот код выполняет последний шаг конвертации. Когда в настройке плагина Выходной формат выбран WebP или AVIF, сконвертированный файл становится вложением, а исходная загрузка удаляется, чтобы сэкономить место на диске. Другие варианты, «AVIF с WebP в качестве резерва» и «Сохранять исходный формат», сохраняют исходные файлы JPEG и PNG. Ветка удаления выглядела так:
// C. Pure 1-to-1 Mode: Delete raw uncompressed source file to free 100% hosting space.if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) { wp_delete_file( $main_file_path );}PHPStan утверждал, что file_exists( $main_file_path ) в этой точке всегда истинно. Причина находится на 150 строк выше, в начале того же метода:
$main_file_path = get_attached_file( $attachment_id );if ( ! $main_file_path || ! file_exists( $main_file_path ) ) { return new WP_Error( 'file_missing', 'File not found on disk.' );}Метод продолжает работу, только если файл существует, и PHPStan переносит этот факт до строки 626.
Мы не приняли находку на веру. file_exists() обращается к файловой системе, и ответ может измениться между двумя вызовами. PHPStan по умолчанию запоминает результат такого вызова (опция rememberPossiblyImpureFunctionValues), исходя из того, что между вызовами на диске ничего не изменилось. Прежде чем удалить проверку, мы прочитали код и убедились в двух вещах:
- Между проверкой в начале метода и строкой 626 ничто не удаляет и не перемещает исходный файл. Конвертация пишет каждый результат во временный файл и перемещает его по новому пути, а единственные вызовы
wp_delete_file()для этого пути удаляют временные файлы. Шаг выгрузки, который действительно удаляет локальные файлы, выполняется после строки 626. wp_delete_file()в любом случае спокойно обрабатывает отсутствующий файл. Она пропускает путь через фильтрwp_delete_fileи вызывает@unlink(), который ничего не ломает, если файла уже нет.
Раз оба пункта подтвердились, условие было мертвым кодом, а мертвый код в ветке удаления вводит следующего читателя в заблуждение: кажется, что файл может исчезнуть посреди метода. Исправление его убрало:
-if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) {+if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path ) { wp_delete_file( $main_file_path ); }Находка не касалась падения. Ее ценность в том, что она показала про метод: допущения кода разошлись с его структурой, причем в единственной ветке, которая удаляет файлы пользователей. Наш вывод уже, чем «доверяй анализатору». Если анализатор сообщает «всегда истинно» или «всегда ложно» для функции файловой системы, сети или времени, прежде чем удалять условие, нужно вручную проверить, что происходит между двумя вызовами.
Автотесты для плагина WordPress без PHPUnit
Последний шаг каждого задания матрицы запускает файлы из tests/. Это не тесты PHPUnit. Каждый файл представляет собой обычный PHP-скрипт, который определяет минимальные стабы для нужных ему функций WordPress (get_option(), update_option(), sanitize_key() и так далее), загружает тестируемый класс плагина, прогоняет его и завершается с ненулевым кодом при первой ошибке:
for t in tests/test_.php do echo "== $t" php "$t" exit 1doneСейчас таких скриптов 9. Они покрывают места, где ошибка стоит клиенту данных или денег: пайплайн конвертации в WebP и AVIF и его резервные варианты, доставку изображений, выгрузку и удаление файлов в облачном хранилище, пакетное восстановление выгруженных файлов, поведение, когда аккаунт перестает платить, квоту хранилища и удаление плагина. Тест удаления выполняет настоящие запросы деинсталляции на SQLite-базе в памяти и проверяет, что осталось:
check( array( '_smallpict_cdn_base', '_smallpict_cdn_key', '_smallpict_offloaded', '_smallpictx', '_wp_attached_file' ) === $meta, 'offload markers and foreign meta remain (got ' . implode( ',', $meta ) . ')');Эта проверка защищает решение, которое легко случайно отменить. При удалении плагин стирает свои настройки, но сохраняет post meta, где записано, какие файлы хранятся только в облачном хранилище, чтобы сайт, переустановивший плагин, всё ещё мог найти свои изображения. Неосторожный DELETE ... LIKE '_smallpict%' удалил бы эти маркеры, и в этом случае тест падает.
Мы выбрали обычные скрипты вместо набора тестов WordPress на PHPUnit ради скорости и простоты настройки: им не нужен сервер базы данных и копия WordPress, а все 9 выполняются меньше чем за секунду, так что запуск на семи версиях PHP почти не удлиняет проверку pull request. Обратная сторона в том, что стабы моделируют только то, что мы в них написали. Поведение, которое зависит от настоящего ядра WordPress, например собственная обработка метаданных в медиатеке, эти тесты не покрывают.
Проверка перед деплоем на WordPress.org
Релизы выходят через отдельный workflow. Его запускает push тега, например v1.2.9, и задание, которое коммитит в SVN WordPress.org, объявляет зависимость от матрицы линтинга:
jobs: lint: name: WPCS Check (PHP ${{ matrix.php-version }}) runs-on: ubuntu-26.04 strategy: matrix: php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - run: composer install --prefer-dist --no-progress --no-interaction - name: PHP CodeSniffer Check run: vendor/bin/phpcs --standard=phpcs.xml.dist
deploy: name: Deploy needs: lint runs-on: ubuntu-26.04 permissions: contents: read steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: '7.4' - name: Build WP.org Version run: | chmod +x build-wporg.sh ./build-wporg.sh - name: Deploy to WordPress.org uses: 10up/action-wordpress-plugin-deploy@stable with: generate-zip: false env: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SLUG: smallpict BUILD_DIR: build-wporg/smallpict ASSETS_DIR: .wordpress-orgneeds: lint означает, что задание deploy стартует только после успеха всех семи заданий lint. Если WPCS падает на любой версии, GitHub пропускает deploy, и коммита в SVN не происходит. Шаг сборки выполняется на PHP 7.4, самой старой из поддерживаемых нами версий. Токен задания имеет доступ к репозиторию только на чтение; единственная запись идет в SVN WordPress.org, с учетными данными из секретов репозитория.

Рисунок 4: Workflow Deploy to WordPress.org. Сначала выполняются пять заданий WPCS Check; задание Deploy под ними запускается только после того, как пройдут все пять.
Проверка заново запускает WPCS на коммите с тегом и не полагается на более ранний прогон pull request. Тег может указывать на любой коммит, в том числе запушенный напрямую или такой, проверки которого так и не завершились, и у релизного workflow нет другого способа узнать, что именно он выпускает.
Локальный запуск проверок CI
Скрипты Composer оборачивают те же команды, что запускает CI, поэтому ошибку можно воспроизвести до push:
composer installcomposer lint # vendor/bin/phpcs --standard=phpcs.xml.distcomposer lint-fix # vendor/bin/phpcbf: fixes spacing and formatting sniffscomposer phpstan # vendor/bin/phpstan analysefor t in tests/test_.php do php "$t" break doneЧтобы воспроизвести ошибку из одного задания матрицы, запустите те же команды под этой версией PHP, например в официальном образе после composer install:
docker run --rm -v "$PWD":/app -w /app php:7.4-cli \ sh -c 'vendor/bin/phpstan analyse --memory-limit=2G && for t in tests/test_*.php; do php "$t" || exit 1; done'phpcbf исправляет только то, что снифф может исправить механически. Для находок по экранированию, санитизации и nonce человек должен решить, какая функция подходит к контексту, поэтому мы не исправляем их автоматически.
Что пайплайн пока не покрывает
В нашем списке три пробела:
- Проверка перед деплоем запускает только WPCS. PHPStan и автотесты выполняются при push в
masterиdevelopи на pull request, но не на теге релиза. Это исправит добавление обоих шагов в заданиеlintworkflow деплоя. - Наш набор правил не включает PHPCompatibility. Если добавить
phpcompatibility/phpcompatibility-wpсtestVersion, равным7.4-, синтаксис и функции, которых нет в 7.4, будут отмечаться за один запуск. - Тесты используют стабы, а не настоящую установку WordPress. Интеграционное задание на WordPress 5.9 (наш минимум) и последнем релизе покроет поведение ядра, которое стабы воспроизвести не могут.
Вопросы и ответы
Почему тестирование на матрице PHP от 7.4 до 8.5 так важно для плагинов WordPress?
WordPress.org доставляет один и тот же релиз на каждый сайт, и заголовок плагина (Requires PHP: 7.4) остается единственным фильтром того, на каких версиях PHP его устанавливают. Между 7.4 и 8.5 в PHP изменилось то, как встроенные функции обрабатывают аргументы неверного типа, как числа сравниваются со строками и какие конструкции вызывают deprecation, поэтому код, корректный на одной версии, может вызвать фатальную ошибку или предупреждения на другой. Прогон CI на одной версии PHP доказывает только то, что плагин работает на этой версии. Запуск PHPStan и тестов на каждой поддерживаемой версии ловит эти различия до того, как релиз дойдет до пользователей.
Заменяет ли WPCS традиционный аудит безопасности?
Нет. WPCS проверяет шаблоны в той строке, где они встречаются: переменная запроса, прочитанная без санитизации, echo без экранирования, обработчик без проверки nonce, запрос без $wpdb->prepare(). Он не может определить, проверяет ли обработчик нужное право, подходит ли функция экранирования к контексту вывода, уходит ли серверный запрос на URL, который контролирует пользователь, и может ли один пользователь добраться до данных другого. Кроме того, комментарий phpcs:ignore может заглушить снифф. Мы рассматриваем WPCS как нижнюю планку, которая не пропускает известные ошибки, а изменения, важные для безопасности, проверяем вручную.
Как PHPStan работает с функциями ядра WordPress, не выполняя код?
PHPStan никогда не запускает анализируемый код. Он читает объявления и докблоки, чтобы построить модель типов. Для WordPress szepeviktor/phpstan-wordpress поставляет файлы-стабы: сигнатуры, типы параметров и возвращаемые типы функций и классов ядра WordPress без их тел. PHPStan читает эти стабы так, будто это WordPress, поэтому может проверить, что вызов wp_delete_file() или get_attached_file() передает и получает правильные типы. Константы, которые WordPress или плагин определяют во время выполнения, помещаются в bootstrap-файл, который PHPStan загружает перед анализом.
Плагин опубликован на WordPress.org как smallPict. Установка и настройки «Выходной формат» описаны в руководстве по установке и справочнике по настройкам изображений.