smallPict 的 WordPress 插件会把上传的图片转换为 WebP 和 AVIF,并提供给访客。我们为某个版本打上 tag 后,GitHub Actions 会把它提交到 WordPress.org 的 SVN 仓库,再从那里分发到所有安装了该插件的网站。开启了自动更新的网站会直接安装新版本,没有任何人审查改动。插件声明了 Requires PHP: 7.4 和 Requires at least: 5.9,因此同一个构建既要能在仍停留在 PHP 7.4 的共享主机上运行,也要能在运行 PHP 8.5 的容器中运行。
插件中的一个 fatal error 就可能让 wp-admin 无法访问。设置页面上一个未转义的值,就是同时出现在所有网站上的 XSS 漏洞。缺少一次 nonce 检查,管理员操作就会成为 CSRF 攻击目标。这些问题都无法悄悄修补:修复需要再发一个版本,而在新版本到达之前,部分网站会一直运行有问题的版本。
本文介绍我们的 WordPress 插件 CI/CD 流水线:在七个 PHP 版本上运行的 WordPress Coding Standards(WPCS)、PHPStan 和自动化测试,以及位于 WordPress.org 之前的部署前关卡。以下内容全部来自 smallpict-wp 仓库的 1.2.9 版本。
要点
- 每个合并到
master的 pull request 都会在 GitHub Actions 中并行地在 PHP 7.4、8.0、8.1、8.2、8.3、8.4 和 8.5 上运行 WPCS、PHPStan 和 9 个自动化测试脚本。- WPCS 强制要求输入经过清理、输出经过转义、使用 nonce 以及预处理查询。PHPStan 在 level 5 下基于 WordPress stub 发现类型错误和永远不会执行的分支,无需运行 WordPress。
- 发布 tag 会在全部七个 PHP 版本上再次运行 WPCS,只有七个版本全部通过后,提交到 WordPress.org SVN 的 job 才会开始。
WordPress 插件 CI/CD 流水线概览
插件由两个 GitHub Actions workflow 把关。
| Workflow | 触发条件 | Job | 检查项 |
|---|---|---|---|
phpcs.yml(PHP Code Quality & Standards) | push 到 master 或 develop,合并到 master 的 pull request | 每个 PHP 版本一个 job: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* tag | 在 7 个 PHP 版本上分别运行 WPCS,然后运行 deploy | WPCS,然后构建并提交到 SVN |
关于 PHP 版本覆盖范围的说明: 本文中的截图展示的是我们早期覆盖 PHP 7.4 至 8.3 的 CI 运行。如今,我们的 GitHub Actions 测试 matrix 已扩展到覆盖 PHP 7.4 至 PHP 8.5,确保与现代 PHP 运行时持续向前兼容,同时在旧环境上保持零回归。
所有工具都是 Composer 的 dev 依赖,因此不会随插件分发给用户:
"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"}在 GitHub Actions 中进行多版本 PHP matrix 测试
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"在 7.4 上为true,在 8.0 上为false,这会让一些针对选项值的松散比较在毫无提示的情况下结果反转。 - PHP 8.0 新增了
str_starts_with()及相关函数。我们的图片处理器调用了str_starts_with()。它能在 PHP 7.4 上运行,只是因为 WordPress 5.9 及以上版本自带 polyfill,而我们的Requires at least: 5.9保证了这一点。 - PHP 8.1 弃用了向内部函数的不可为 null 的参数传入
null。strtolower( wp_parse_url( $url, PHP_URL_HOST ) )在 7.4 上没有问题,但在 8.1 上,当$url是相对地址、host 为null时会记录一条弃用提示。有些主机会把弃用提示直接输出到页面上。 - PHP 8.2 弃用了动态属性。在您自己的类上给未声明的属性赋值,每个请求都会记录一条弃用提示。
这些问题只有在行为不同的 PHP 版本上分析或执行代码时才会显现。正因如此,流水线会让每个合并到 master 的 pull request 同时在全部七个版本上运行。
GitHub Actions matrix

图 1:将 1.2.0 的工作合并到 master 时的 PHP Code Quality & Standards workflow。每个 PHP 版本都是一个独立的 job,PHP 8.3 的 job 展示了每个 job 都会执行的步骤:PHPCS、PHPStan,然后是自动化测试。
关于 PHP 版本覆盖范围的说明: 上方的截图展示的是我们早期覆盖 PHP 7.4 至 8.3 的 CI 运行。如今,我们的 GitHub Actions 测试 matrix 已扩展到覆盖 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 会取消仍在运行的 job。这样一个有问题的 pull request 花费的资源很少,代价是无法在同一次运行中看到每个版本的结果。
matrix 能证明什么,不能证明什么
三项检查从 matrix 中获得的价值并不相同。PHPStan 针对其运行所在的 PHP 版本分析代码,测试则在该版本上执行代码,所以两者在每个 job 中都可能给出不同的结果。PHPCS 基于 token 工作,实际上在七个版本上给出的结果相同;在 matrix 中,它主要证明工具链能在每个版本上安装并运行。一次性针对一系列 PHP 版本检查语法和函数,是 PHPCompatibility 的工作,而我们的规则集目前还没有包含它。
自动化 WordPress Coding Standards(WPCS)
对插件至关重要的安全 sniff
WPCS 是一组专为 WordPress 编写的 PHP_CodeSniffer sniff。对插件安全最重要的规则就在 WordPress 规则集本身中:
WordPress.Security.ValidatedSanitizedInput会标记所有未经 unslash 和清理就读取$_POST、$_GET、$_REQUEST或$_COOKIE的地方。WordPress.Security.EscapeOutput会标记所有对未经esc_html()、esc_attr()或esc_url()等转义函数处理的值进行echo或print的地方。WordPress.Security.NonceVerification会标记未验证 nonce 就读取请求数据的处理函数。WordPress.DB.PreparedSQL和PreparedSQLPlaceholders会标记通过字符串拼接而非$wpdb->prepare()构建的查询,以及与参数不匹配的占位符。
我们的 WPCS 规则集
phpcs.xml.dist 添加了 WordPress-Extra 和 WordPress-Docs,为翻译相关的 sniff 固定了 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>一个通过 WPCS 的 AJAX 处理函数
插件中的这个处理函数会分批恢复已迁移到云存储的媒体。所有输入边界都在开头几行处理:
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()。
抑制规则必须写明 sniff 名称和原因
上面那行 phpcs:ignore 是有意为之。WPCS 会对未使用对象缓存的直接数据库查询发出警告,这对前端读取是好建议,但对每次调用时数据行都会变化的管理员操作并不适用。我们的规则是:每个 ignore 都必须写明确切的 sniff 名称,并在 -- 之后给出原因。不带说明的 // phpcs:ignore 无法通过代码审查。
在 CI 中,PHPCS 会输出 checkstyle 报告,cs2pr 再把它转换成 pull request diff 上的注释,这样审查者可以直接在出问题的那一行看到每条违规,而不必去翻日志。
用 PHPStan 静态分析发现逻辑缺陷
WPCS 逐行读取代码。它知道某个 echo 缺少转义调用,但不知道变量持有什么类型,也不知道某个分支是否有可能执行。PHPStan 会为流经每个函数的类型建立模型,并报告与模型矛盾的代码:调用不存在的方法、在需要 string 的地方传入 string|false、比较永远不可能相等的值、条件永远为 true 或永远为 false。
在不运行 WordPress 的情况下对 WordPress 使用 PHPStan
szepeviktor/phpstan-wordpress 通过 stub 文件向 PHPStan 提供 WordPress 核心函数和类的签名,并附带几个 WordPress 专用的类型扩展。phpstan/extension-installer 会自动注册它。PHPStan 只读取这些声明,从不加载 WordPress 或数据库。
我们的 PHPStan 配置
parameters: level: 5 paths: - smallpict.php - includes/ - admin/ treatPhpDocTypesAsCertain: false bootstrapFiles: - phpstan-bootstrap.php在较低 level 对未知类、方法和属性、返回类型以及死代码的检查之上,level 5 还会检查传给函数或方法的每个参数的类型。treatPhpDocTypesAsCertain: false 告诉 PHPStan 不要把我们自己写的 @param docblock 当作确凿依据。WordPress 会原样传递 filter 返回的任何值,所以一个注明为 int 的参数仍可能以字符串形式从其他插件传进来,我们希望 PHPStan 保留处理这种情况的防御性检查。bootstrap 文件定义了插件的常量(SMALLPICT_PATH、SMALLPICT_URL 等),这些常量通常在运行时才设置。
![GitHub Actions 中 WordPress 的 PHPStan 静态分析日志:WPCS 步骤无问题,PHPStan 分析了 11 个插件文件中的 11 个,结果为“[OK] No errors”](/blog-assets/02_phpcs_phpstan_log.png)
图 2:PHP 8.3 job 中的 PHPCS 和 PHPStan 步骤。没有需要报告的内容时,PHPCS 不输出任何信息。PHPStan 分析了 11 个插件文件(插件主文件和 includes/ 中的 10 个类),没有报告错误。
一个真实的 PHPStan 发现:图片处理器第 626 行
2026 年 10 月 2 日,流水线因 PHPStan 报出一个错误而失败:

图 3:PHP 8.2 job 在 PHPStan 步骤停止,报告 includes/class-smallpict-image-processor.php 第 626 行的 booleanAnd.rightAlwaysTrue。当时被分析的路径中共有 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 ) 永远为 true。原因在 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 选项),前提是两次调用之间没有任何操作改变磁盘。在删除这项检查之前,我们通过阅读代码确认了两点:
- 从 guard 到第 626 行之间,没有任何代码删除或移动源文件。转换流程会把每个输出写入临时文件,再移动到新路径;针对该路径的
wp_delete_file()调用只用于删除临时文件。确实会删除本地文件的迁移到云存储步骤,在第 626 行之后才运行。 - 即使文件不存在,
wp_delete_file()也能正常处理。它会让路径经过wp_delete_filefilter,然后调用@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 ); }这个发现并不是崩溃。它的价值在于揭示了这个方法的问题:在唯一会删除用户文件的分支里,代码的假设已经与其结构脱节。我们得到的教训比“信任分析器”更具体:当 PHPStan 对文件系统、网络或时间相关函数报告永远为 true 或永远为 false 时,在删除条件之前,应当手动检查两次调用之间会发生什么。
不依赖 PHPUnit 的 WordPress 插件自动化测试
每个 matrix job 的最后一步会运行 tests/ 中的文件。这些不是 PHPUnit 测试。每个文件都是一个普通的 PHP 脚本:为它需要的 WordPress 函数定义最小化的 stub(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 的检查时间。代价是 stub 只能模拟我们写进去的行为。依赖真实 WordPress 核心的行为,例如媒体库自身的元数据处理,不在这些测试的覆盖范围内。
发布到 WordPress.org 之前的部署前关卡
版本通过一个独立的 workflow 发布。push 一个如 v1.2.9 这样的 tag 会触发它,而提交到 WordPress.org SVN 的 job 声明了对 lint matrix 的依赖:
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 表示只有全部七个 lint job 都成功后,deploy job 才会开始。只要 WPCS 在任一版本上失败,GitHub 就会跳过 deploy,不会产生任何 SVN 提交。构建步骤在我们支持的最旧版本 PHP 7.4 上运行。该 job 的 token 对仓库只有只读权限;它唯一的写入操作是提交到 WordPress.org SVN,所用凭据保存在仓库 secret 中。

图 4:Deploy to WordPress.org workflow。五个 WPCS Check job 先运行;下方的 Deploy job 只有在五个全部通过后才会运行。
这个关卡会在打了 tag 的 commit 上重新运行 WPCS,而不是信任之前 pull request 的运行结果。tag 可以指向任意 commit,包括直接 push 的 commit 或检查从未完成的 commit,而发布 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要复现某个 matrix job 的失败,请在对应的 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 只修复 sniff 能够机械修复的问题。转义、清理和 nonce 相关的发现需要由人来判断哪个函数适合当前上下文,我们不会自动修复它们。
流水线尚未覆盖的部分
我们的待办清单上有三处缺口:
- 部署前关卡只运行 WPCS。PHPStan 和自动化测试在 push 到
master和develop以及 pull request 时运行,但不会在发布 tag 上运行。把这两个步骤加入部署 workflow 的lintjob 即可补上这一缺口。 - 我们的规则集没有包含 PHPCompatibility。添加
phpcompatibility/phpcompatibility-wp并将testVersion设为7.4-,就能在一次运行中标记出 7.4 上不存在的语法和函数。 - 测试使用的是 stub,而不是真实的 WordPress 安装。针对 WordPress 5.9(我们支持的最低版本)和最新版本运行的集成测试 job,可以覆盖 stub 无法模拟的核心行为。
FAQ
为什么在 PHP 7.4 至 8.5 上进行 matrix 测试对 WordPress 插件至关重要?
WordPress.org 会把同一个版本分发给每个网站,而插件头(Requires PHP: 7.4)是决定哪些 PHP 版本可以安装它的唯一过滤条件。从 7.4 到 8.5,PHP 改变了内部函数处理错误参数类型的方式、数字与字符串的比较方式,以及哪些写法会触发弃用提示,因此在一个版本上正确的代码,在另一个版本上可能导致 fatal error 或记录警告。只在单个 PHP 版本上运行 CI,只能证明插件在那个版本上可用。在每个受支持的版本上运行 PHPStan 和测试,可以在版本到达用户之前发现这些差异。
WPCS 能取代传统的安全审计吗?
不能。WPCS 检查的是模式所在的那一行:未经清理就读取的请求变量、没有转义的 echo、没有 nonce 检查的处理函数、没有使用 $wpdb->prepare() 的查询。它无法判断处理函数检查的 capability 是否正确,转义函数是否适合输出上下文,服务器端请求是否会发往用户可控的 URL,或者一个用户能否访问另一个用户的数据。phpcs:ignore 注释也可以让某个 sniff 静默。我们把 WPCS 视为挡住已知错误的底线,涉及安全的改动仍由人工审查。
PHPStan 如何在不执行代码的情况下处理 WordPress 核心函数?
PHPStan 从不运行它分析的代码。它读取声明和 docblock 来建立类型模型。对于 WordPress,szepeviktor/phpstan-wordpress 提供 stub 文件:WordPress 核心函数和类的签名、参数类型和返回类型,但不含函数体。PHPStan 把这些 stub 当作 WordPress 本身来读取,因此能够检查对 wp_delete_file() 或 get_attached_file() 的调用是否传入并接收了正确的类型。WordPress 或插件在运行时定义的常量放在 bootstrap 文件中,PHPStan 会在分析前加载它。
该插件已在 WordPress.org 上架,名称为 smallPict。安装方法和输出格式设置请参阅安装指南和图片设置参考。