Commit 5e224e644eb for woocommerce
commit 5e224e644eb92df319137c2db49d96993577ca5c
Author: Jamel Noel Reid <MrJnrman@users.noreply.github.com>
Date: Tue Nov 10 04:49:46 2026 -0500
Substitute PHP unit, PHPStan and JS build jobs from local CI receipts (#69582)
* [DEVPROD-1223] Add the local CI receipt consumer and .github/local-ci.json
* [DEVPROD-1223] Address pre-push review findings on the receipt consumer
* [DEVPROD-1223] Address a8c-review and Codex findings on the receipt consumer
* [DEVPROD-1223] Trust allowlisted authors and write permission, not only author_association
* [DEVPROD-1223] Move who-may-publish to the tool's allowlist; CI checks permission or membership
* [DEVPROD-1223] Remove spot checks; CI relies on write-access trust
* [DEVPROD-1223] Fix a stale comment
* [DEVPROD-1223] Ship the receipt consumer enabled
* [DEVPROD-1223] Document the consumer and the live-test runbook
* [DEVPROD-1223] Test the receipt consumer live instead of with unit tests
* [DEVPROD-1223] Point the file comment at the live runbook
* [DEVPROD-1223] Shorten the file comment
* [DEVPROD-1223] Trim comments and descriptions
* [DEVPROD-1223] Require confirmed write permission; drop the association fallback
* [DEVPROD-1223] Give each jest job four workers
* [DEVPROD-1242] Substitute PHP unit cells from receipts
Adds an opt-in unit:php rule to .github/local-ci.json that hands each core-plugin cell to bin/test-php-ci.sh (serial; ports 92xx so the worktree never collides with the developer's own cells; pre-release and package jobs excluded). The consumer accepts unit:php and ci.yml now guards both Start Test Environment steps, which a substituted PHP job would otherwise run without the install.
* [DEVPROD-1242] Substitute PHP unit, PHPStan and JS build jobs from receipts
Reapplies f73aef2599f on the consumer trunk has now: unit:php joins the
substitutable types, ci.yml guards both Start Test Environment steps, and
an opt-in php-cells rule hands each core plugin cell to bin/test-php-ci.sh.
Adds three rules that run under --docker on pinned PHP images:
- phpstan, for the package PHPStan jobs, on PHP 8.3 as in CI.
- php-unit-8.1, for the PHP 8.1 jobs with no test environment.
- js-build, for the JS build and typecheck jobs, natively too.
Each samples 10% in CI, like the JavaScript rule. Every rule now has an id,
since two share a type. The default image becomes PHP 8.3 with Composer and
Node, built from php.Dockerfile, because pnpm install runs composer install.
* [DEVPROD-1242] Run the PHPUnit rule on PHP 7.4 and pin the Composer image
CI installs PHP 7.4 for every unit:php job, whatever the job name says:
the PHP: 8.1 WP: latest job for woocommerce-analytics ran on 7.4.33 in
run 42612256733. The rule now uses a php:7.4-cli-bullseye image, so a
local pass means the same as a CI pass. Bullseye is past end of life, so
the Dockerfile reads its packages from archive.debian.org.
The Composer stage is now pinned to 2.10.3 by digest, like the Node and
PHP images.
* [DEVPROD-1242] Sample PHP cell receipts and note who runs the image
php-cells had no sampleRate, so CI never re-ran a core PHP cell to check
it against its receipt. It now samples 10%, like the other rules.
The php-unit comment now says that gh local-ci runs a rule that names an
image only with --docker, so its receipts never come from the host's PHP.
The Dockerfile says why it sets no USER: gh local-ci runs jobs as the
host's uid:gid on Linux.
---------
Co-authored-by: Ajitem Sahasrabuddhe <ajitem.s@outlook.com>
diff --git a/.github/actions/local-ci-receipt/AGENTS.md b/.github/actions/local-ci-receipt/AGENTS.md
index e8e001c150e..c52860dbef5 100644
--- a/.github/actions/local-ci-receipt/AGENTS.md
+++ b/.github/actions/local-ci-receipt/AGENTS.md
@@ -75,6 +75,36 @@ on `FEATURE`, cherry-pick onto **both** scratch branches (the head config must
stay byte-identical to the base config), run `gh local-ci run --push` on the
child again (new head → new receipts), and re-check.
+## 4b. PHP cells (opt-in)
+
+PHP unit cells are `unit:php` jobs for `@woocommerce/plugin-woocommerce`;
+the rule in `.github/local-ci.json` is opt-in and hands each cell to
+`bin/test-php-ci.sh`. On the child:
+
+```sh
+printf '\n// live receipt test marker\n' >> plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
+git commit -am "Touch a core PHP test for the live receipt test" --no-verify
+gh local-ci plan --include=unit:php # the 4 core cells with `bash bin/test-php-ci.sh --cell …` commands, plus the JavaScript jobs from §2
+gh local-ci run --include=unit:php --push
+```
+
+Cells run one after another (about 25 min; the first run builds a wp-env
+instance per cell, on ports 92xx, in the tool's worktree). Then, for each
+`PHP: … [unit:php]` job on the PR run: conclusion `success`, about 10 s,
+with `Install Monorepo`, both `Start Test Environment` steps and
+`Run tests (unit:php)` skipped, and the `substituted=true` line in the log.
+Check the step conclusions, not just the log line; a dropped guard on the
+`increase max-download-attempts` step leaves the job green but slower:
+
+```sh
+gh api repos/woocommerce/woocommerce/actions/jobs/<job id> --jq '.steps[] | "\(.conclusion)\t\(.name)"'
+```
+
+A cell that failed locally gets a failure receipt and the job runs in full
+with `reason=newest receipt … is not a success` in the log.
+Stop any cells you started yourself with `pnpm test:php:ci` first; they share
+Docker memory with the worktree's.
+
## 5. Kill switch
Set the repository variable to `1`, then trigger a **new** run (a re-run
diff --git a/.github/actions/local-ci-receipt/README.md b/.github/actions/local-ci-receipt/README.md
index 293906a0586..e4a6c43b159 100644
--- a/.github/actions/local-ci-receipt/README.md
+++ b/.github/actions/local-ci-receipt/README.md
@@ -68,7 +68,7 @@ This action is tested live, against real GitHub, not with unit tests: the
only test that sees what GitHub actually sends is a real pull request, which
is how the `author_association` problem was found. The **live test is
required** for any change to `receipt.js`, `action.yml`, the `ci.yml` step,
-its two `if:` guards or the comparison step, or `.github/local-ci.json`. `pr-check-local-actions.yml`
+its four `if:` guards or the comparison step, or `.github/local-ci.json`. `pr-check-local-actions.yml`
still validates `.github/local-ci.json` on every change to it.
The live test uses a scratch branch as a stand-in for trunk, so nothing
@@ -83,6 +83,7 @@ What it proves, in one PR:
| Check | Expected in the job log |
|---|---|
| Positive path | `substituted=true reason=receipt … by <login> (has <permission> permission)`; `Install Monorepo` and `Run tests (unit)` skipped; job ≈ 10 s |
+| Positive path, PHP cell | same line; `Install Monorepo`, both `Start Test Environment` steps and `Run tests (unit:php)` skipped; job ≈ 10 s instead of 15–18 min |
| Kill switch (new run with the variable set) | `substituted=false reason=kill switch LOCAL_CI_RECEIPTS_DISABLED=1` |
| Config changed in the PR | `substituted=false reason=.github/local-ci.json differs …` |
| Receipts absent | `substituted=false reason=no receipt named …` |
diff --git a/.github/actions/local-ci-receipt/receipt.js b/.github/actions/local-ci-receipt/receipt.js
index 2d8d7459bbc..9d616eb9239 100644
--- a/.github/actions/local-ci-receipt/receipt.js
+++ b/.github/actions/local-ci-receipt/receipt.js
@@ -14,7 +14,7 @@ const CONFIG_PATH = '.github/local-ci.json';
const WRITE_PERMISSIONS = [ 'admin', 'maintain', 'write' ];
const OFF_VALUES = [ '', '0', 'false', 'off', 'no' ];
// Only job types whose remaining steps ci.yml guards.
-const SUBSTITUTABLE_TYPES = [ 'unit' ];
+const SUBSTITUTABLE_TYPES = [ 'unit', 'unit:php' ];
const RECEIPT_VERSION = 'v1';
// Local results worth comparing with CI's; `cancelled` is not a result.
const COMPARABLE_CONCLUSIONS = [ 'success', 'failure', 'timed_out' ];
diff --git a/.github/local-ci.json b/.github/local-ci.json
index b645a8eb994..8283ba8446f 100644
--- a/.github/local-ci.json
+++ b/.github/local-ci.json
@@ -22,6 +22,7 @@
"eligible": [
{
+ "id": "js-unit",
"type": "unit",
"namePrefix": "JavaScript",
"sampleRate": 10,
@@ -40,19 +41,108 @@
},
"$comment": "Every test:js is jest, which defaults to cores-1 workers per invocation. gh local-ci sizes {workers} for the machine it runs on, from a budget the jobs share; on a Mac with more than one core tier, from the fastest tier only. A fixed 4 per job oversubscribed an M5 Max's 6 fast cores: the seven jest jobs of one diff took 83 s, against 65 to 73 s sized by the tool.",
"run": [ "pnpm", "--filter={project}", "{command}", "--maxWorkers={workers}" ]
+ },
+ {
+ "id": "js-build",
+ "type": "unit",
+ "namePrefix": "JS build and typecheck",
+ "sampleRate": 10,
+ "prepare": {
+ "$comment": "Mirrors setup-woocommerce-monorepo with install='<project>...' and build-type=dependencies.",
+ "env": { "CI": "true", "GITHUB_ACTIONS": "true", "BROWSERSLIST_IGNORE_OLD_DATA": "true" },
+ "clean": [ "plugins/woocommerce/assets/client/blocks" ],
+ "steps": [
+ [ "pnpm", "install", "{projects:--filter=%s...}", "--frozen-lockfile" ],
+ [ "pnpm", "--if-present", "--workspace-concurrency=Infinity", "--stream", "{projects:--filter=%s^...}", "/^build:project:.*$/" ]
+ ],
+ "mirrors": {
+ "action": ".github/actions/setup-woocommerce-monorepo/action.yml",
+ "sha256": "9aa17d624c6b2f4e5666763e9354b424c5c988f1b6e506d26ca8df40ad9c0fa0"
+ }
+ },
+ "run": [ "pnpm", "--filter={project}", "{command}" ]
+ },
+ {
+ "id": "phpstan",
+ "$comment": "CI skips setup-php for build-type=dependencies, so PHPStan runs on the runner's PHP 8.3 whatever the job name says; the name's version picks the neon config. run-phpstan.sh installs into and then deletes tasks/phpstan/vendor, so one project's two PHPStan jobs run in turn.",
+ "type": "unit",
+ "namePrefix": "PHPStan: ",
+ "image": "php8.3",
+ "serial": "phpstan-{project}",
+ "sampleRate": 10,
+ "prepare": {
+ "$comment": "Mirrors setup-woocommerce-monorepo with install='<project>...' and build-type=dependencies.",
+ "env": { "CI": "true", "GITHUB_ACTIONS": "true", "BROWSERSLIST_IGNORE_OLD_DATA": "true" },
+ "clean": [ "plugins/woocommerce/assets/client/blocks" ],
+ "steps": [
+ [ "pnpm", "install", "{projects:--filter=%s...}", "--frozen-lockfile" ],
+ [ "pnpm", "--if-present", "--workspace-concurrency=Infinity", "--stream", "{projects:--filter=%s^...}", "/^build:project:.*$/" ]
+ ],
+ "mirrors": {
+ "action": ".github/actions/setup-woocommerce-monorepo/action.yml",
+ "sha256": "9aa17d624c6b2f4e5666763e9354b424c5c988f1b6e506d26ca8df40ad9c0fa0"
+ }
+ },
+ "run": [ "pnpm", "--filter={project}", "{command}" ]
+ },
+ {
+ "id": "php-unit",
+ "$comment": "PHPUnit without wp-env. The test job installs PHP 7.4 for every unit:php job (setup-woocommerce-monorepo's default), so this runs on 7.4 whatever the job name says. gh local-ci runs a rule that names an image only with --docker, so a receipt for it never comes from the host's PHP. Jobs with a test environment have [WP ...] in their names: the core plugin's are the php-cells rule's, and the rest are left to CI.",
+ "type": "unit:php",
+ "namePrefix": "PHP: 8.1 ",
+ "exclude": [ "[WP " ],
+ "image": "php7.4",
+ "sampleRate": 10,
+ "prepare": {
+ "$comment": "Mirrors setup-woocommerce-monorepo with install='<project>...' and build-type=backend.",
+ "env": { "CI": "true", "GITHUB_ACTIONS": "true", "BROWSERSLIST_IGNORE_OLD_DATA": "true" },
+ "steps": [
+ [ "pnpm", "install", "{projects:--filter=%s...}", "--frozen-lockfile" ],
+ [ "pnpm", "--filter=@woocommerce/admin-library", "build:project:feature-config" ]
+ ],
+ "mirrors": {
+ "action": ".github/actions/setup-woocommerce-monorepo/action.yml",
+ "sha256": "9aa17d624c6b2f4e5666763e9354b424c5c988f1b6e506d26ca8df40ad9c0fa0"
+ }
+ },
+ "run": [ "pnpm", "--filter={project}", "{command}" ]
+ },
+ {
+ "id": "php-cells",
+ "$comment": "Core plugin PHP cells only; grow `exclude` when packages/php/* gain unit:php jobs. The driver resolves each cell's PHP and WordPress versions from the planner itself. Opt-in: four cells run one after another, about 25 min. Native runs only: each cell starts wp-env, which needs a Docker daemon that a --docker container does not have.",
+ "type": "unit:php",
+ "exclude": [
+ "WP: pre-release",
+ "@woocommerce/blueprint",
+ "@woocommerce/email-editor-config",
+ "@automattic/woocommerce-subscriptions-engine",
+ "@automattic/woocommerce-analytics"
+ ],
+ "serial": "wp-env",
+ "enabledByDefault": false,
+ "sampleRate": 10,
+ "prepare": {
+ "$comment": "Ports 92xx so the worktree's wp-env cells never collide with cells started from the developer's checkout.",
+ "env": { "TEST_PHP_CI_PORT_BASE": "92" }
+ },
+ "run": [ "bash", "bin/test-php-ci.sh", "--cell", "{name}" ]
}
],
"docker": {
- "$comment": "For run --docker. gh local-ci builds the image from .github/local-ci/Dockerfile as committed, and rebuilds it only when that directory changes. The pnpm store lives on the per-clone HOME volume, so no mounts are needed.",
- "build": { "context": ".github/local-ci" }
+ "$comment": "For run --docker. gh local-ci builds each image from .github/local-ci/php.Dockerfile as committed, and rebuilds it only when that directory or its args change. The default image is PHP 8.3 with Composer and Node, as on the ubuntu-24.04 runner, because pnpm install runs composer install in the postinstall of plugins/woocommerce, the beta tester and four PHP packages. The pnpm store lives on the per-clone HOME volume, so no mounts are needed.",
+ "build": { "context": ".github/local-ci", "dockerfile": "php.Dockerfile", "args": { "PHP_IMAGE": "php:8.3-cli-bookworm@sha256:cdb77e306d6bb99db40e513fbe036ecc6c53b575cbcb7e581387845fef26d755" } },
+ "images": {
+ "php8.3": { "build": { "context": ".github/local-ci", "dockerfile": "php.Dockerfile", "args": { "PHP_IMAGE": "php:8.3-cli-bookworm@sha256:cdb77e306d6bb99db40e513fbe036ecc6c53b575cbcb7e581387845fef26d755" } } },
+ "php7.4": { "build": { "context": ".github/local-ci", "dockerfile": "php.Dockerfile", "args": { "PHP_IMAGE": "php:7.4-cli-bullseye@sha256:620a6b9f4d4feef2210026172570465e9d0c1de79766418d3affd09190a7fda5" } } }
+ }
},
"lockfile": "pnpm-lock.yaml",
"toolchain": [
{
- "$comment": "CI's Node: setup-woocommerce-monorepo runs setup-node with node-version-file .nvmrc. Locally every job runs through pnpm, which uses useNodeVersion from pnpm-workspace.yaml whatever node is on PATH, so the check asks pnpm, not PATH. pnpm is not checked: action-setup reads it from packageManager in package.json, and expectFrom takes a file that holds only the version. Nor is PHP: the JavaScript rule's build-type dependencies skips setup-php.",
+ "$comment": "CI's Node: setup-woocommerce-monorepo runs setup-node with node-version-file .nvmrc. Locally every job runs through pnpm, which uses useNodeVersion from pnpm-workspace.yaml whatever node is on PATH, so the check asks pnpm, not PATH. pnpm is not checked: action-setup reads it from packageManager in package.json, and expectFrom takes a file that holds only the version. Nor is PHP: the PHPStan and PHP 8.1 rules run only under --docker, where the image is the toolchain, and each PHP cell runs on wp-env's PHP.",
"run": "pnpm exec node -v",
"expectFrom": ".nvmrc"
}
diff --git a/.github/local-ci/Dockerfile b/.github/local-ci/Dockerfile
deleted file mode 100644
index db9f4149135..00000000000
--- a/.github/local-ci/Dockerfile
+++ /dev/null
@@ -1,16 +0,0 @@
-# The image `gh local-ci run --docker` cuts its containers from. The tool
-# builds it from this file as committed, with this directory as the context,
-# and rebuilds it only when something here changes. Keep the Node and pnpm
-# versions in step with .nvmrc and the packageManager field in package.json.
-#
-# Every job gets a fresh container and only named volumes survive between
-# them, so pnpm must be in the image itself: a setup step that installs it
-# would be gone by the time the jobs start.
-#
-# The full Debian image, not -slim, because node-gyp needs python3, make and g++.
-FROM node:24.15.0-bookworm@sha256:f22d6a1f082c02f292e86929b5b0442ac2e5eaf438a5dea9b1566601c3e05940
-
-# The standalone pnpm, as pnpm/action-setup installs it in CI, rather than
-# corepack, whose cache would sit under the build-time HOME and not the
-# /home/local-ci volume the tool mounts at run time.
-RUN npm install --global pnpm@10.33.0 && npm cache clean --force
diff --git a/.github/local-ci/php.Dockerfile b/.github/local-ci/php.Dockerfile
new file mode 100644
index 00000000000..e64fde41955
--- /dev/null
+++ b/.github/local-ci/php.Dockerfile
@@ -0,0 +1,44 @@
+# The images `gh local-ci run --docker` cuts its containers from: the default
+# one (PHP 8.3, like the ubuntu-24.04 runner) and one per entry in
+# docker.images. PHP_IMAGE is the official PHP CLI image, pinned by digest in
+# .github/local-ci.json; the Node and Composer images are pinned by digest
+# here. The tool rebuilds an image only when this directory or its args change.
+#
+# Every image needs Composer as well as Node, because pnpm install runs
+# composer install in several workspace postinstall scripts. Node and pnpm are
+# the same in every image: they share the worktree's node_modules, and native
+# modules are built for one Node ABI. Keep them in step with .nvmrc and the
+# packageManager field in package.json.
+#
+# Every job gets a fresh container and only named volumes survive between
+# them, so pnpm must be in the image itself.
+ARG PHP_IMAGE
+FROM node:24.15.0-bookworm@sha256:f22d6a1f082c02f292e86929b5b0442ac2e5eaf438a5dea9b1566601c3e05940 AS node
+FROM composer:2.10.3@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer
+
+FROM ${PHP_IMAGE}
+
+# What composer needs to fetch and unpack packages, what node-gyp needs to
+# build native modules, and the extensions setup-php enables by default that
+# the official image lacks. PHP 7.4 images are Debian bullseye, which is past
+# end of life and served only from archive.debian.org.
+RUN . /etc/os-release \
+ && if [ "$VERSION_CODENAME" = bullseye ]; then sed -i 's#http://deb.debian.org#http://archive.debian.org#' /etc/apt/sources.list; fi \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends git unzip libicu-dev libzip-dev python3 make g++ \
+ && docker-php-ext-install intl zip bcmath \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer /usr/bin/composer /usr/local/bin/composer
+COPY --from=node /usr/local/bin/node /usr/local/bin/node
+COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
+RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
+ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
+ && npm install --global pnpm@10.33.0 && npm cache clean --force
+
+# No USER: gh local-ci picks the user. On Linux it runs jobs as the host's
+# uid:gid and hands it the volumes first; elsewhere the daemon maps file
+# ownership, so the image default is enough.
+#
+# PHPStan analyses with --memory-limit=2G; the CLI default is 128M.
+RUN echo "memory_limit = -1" > "$PHP_INI_DIR/conf.d/local-ci.ini"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0595d6782b7..5228ceb0fe0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -327,7 +327,7 @@ jobs:
# Skip install and tests when a `gh local-ci` receipt vouches for this
# job on this commit. Off switch: repository variable
- # LOCAL_CI_RECEIPTS_DISABLED=1. Rollback: revert this step, the two
+ # LOCAL_CI_RECEIPTS_DISABLED=1. Rollback: revert this step, the four
# guards below and the comparison step after the tests. See
# .github/actions/local-ci-receipt/README.md.
- uses: './.github/actions/local-ci-receipt'
@@ -385,7 +385,7 @@ jobs:
run: node .github/workflows/scripts/override-wp-env-plugins.js
- name: 'Start Test Environment: increase max-download-attempts'
- if: ${{ matrix.testEnv.shouldCreate }}
+ if: ${{ matrix.testEnv.shouldCreate && steps.local-ci-receipt.outputs.substituted != 'true' }}
uses: docker/setup-docker-action@6d7cfa65f60a9dda7b46e5513fa982536f3c9877 # v5.3.0
with:
daemon-config: |
@@ -396,7 +396,7 @@ jobs:
- name: 'Start Test Environment: start'
id: 'prepare-test-environment'
- if: ${{ matrix.testEnv.shouldCreate }}
+ if: ${{ matrix.testEnv.shouldCreate && steps.local-ci-receipt.outputs.substituted != 'true' }}
# `wp-env start` fails on transient network timeouts from several subsystems:
# composer fetching packagist inside the Docker build ("curl error 28"), wp-env's
# own `got` client downloading WordPress core ("ETIMEDOUT"/"socket hang up"), and